├── .dockerignore ├── .editorconfig ├── .eslintrc.js ├── .github └── workflows │ └── node.js.yml ├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── docker-compose ├── certcacheclient │ ├── docker-compose.yml │ ├── nginx │ │ └── config │ │ │ └── 000-default.conf │ └── www │ │ └── index.html ├── certcacheserver │ └── docker-compose.yml └── standalone │ ├── docker-compose.yml │ ├── nginx │ └── config │ │ └── 000-default.conf │ └── www │ └── index.html ├── docker ├── entrypoint.sh └── requirements.txt ├── docs ├── Config directives.md ├── Configure challenges.md ├── Debugging problems.md ├── ECDSA Certificates.md ├── Installing certcache client.md ├── Installing certcache server.md ├── Standalone mode.md ├── Using certificates.md └── images │ ├── 93million_logo.svg │ ├── certcache_logo.svg │ ├── dns-01_diagram.svg │ └── http-01_diagram.svg ├── jest.config.all.js ├── jest.config.js ├── jest.config.sit.js ├── package.json ├── sit ├── bin │ ├── cnf │ │ └── ca.cnf │ ├── createca.sh │ └── createcert.sh ├── deps │ └── ngrok-stable-linux-amd64.zip ├── filepaths.js ├── index.test.js ├── lib │ ├── setupTests.js │ └── startNgrok.js └── skel │ └── server │ └── conf │ └── settings.json └── src ├── bin └── generate-access-keys.sh ├── cli ├── cli.js └── commands │ ├── add-client.js │ ├── args │ └── index.js │ ├── client.js │ ├── create-keys.js │ ├── get.js │ ├── index.js │ ├── info.js │ ├── ls.js │ ├── serve.js │ ├── sync.js │ └── testCmd.js ├── config ├── config.js ├── config.test.js ├── defaults.js └── defaults.test.js ├── extensions ├── certbot │ ├── canGenerateDomains.js │ ├── canGenerateDomains.test.js │ ├── commandArgs.js │ ├── config.js │ ├── config.test.js │ ├── generateCert.js │ ├── generateCert.test.js │ ├── getBundle.js │ ├── getBundle.test.js │ ├── getLocalCerts.js │ ├── getLocalCerts.test.js │ ├── getMetaFromCert.js │ ├── getMetaFromCert.test.js │ ├── getMetaFromCertDefinition.js │ ├── getMetaFromCertDefinition.test.js │ ├── getMetaFromConfig.js │ ├── getMetaFromConfig.test.js │ ├── index.js │ ├── lib │ │ ├── canonicaliseDomains.js │ │ ├── canonicaliseDomains.test.js │ │ ├── challenges │ │ │ ├── dns01.js │ │ │ ├── http01.js │ │ │ └── index.js │ │ ├── execCertbot.js │ │ ├── generateCertName.js │ │ ├── generateCertName.test.js │ │ ├── getCertbotCertonlyArgs.js │ │ ├── getCertbotCertonlyArgs.test.js │ │ ├── getChallengeFromDomains.js │ │ └── getChallengeFromDomains.test.js │ ├── normalizeMeta.js │ └── normalizeMeta.test.js └── thirdparty │ ├── config.js │ ├── getBundle.js │ ├── getBundle.test.js │ ├── getLocalCerts.js │ ├── getLocalCerts.test.js │ ├── index.js │ └── lib │ ├── CertFinder.js │ ├── CertFinder.test.js │ ├── fileIsCert.js │ ├── fileIsCert.test.js │ ├── fileIsKey.js │ ├── fileIsKey.test.js │ ├── readFirstLine.js │ ├── readFirstLine.test.js │ ├── readdirRecursive.js │ └── readdirRecursive.test.js └── lib ├── FeedbackError.js ├── __mocks__ ├── getArgv.js └── getConfig.js ├── canonicaliseUpstreamConfig.js ├── canonicaliseUpstreamConfig.test.js ├── classes ├── Certificate.js └── Certificate.test.js ├── client ├── __mocks__ │ └── canonicaliseCertDefinitions.js ├── canonicaliseCertDefinitions.js ├── canonicaliseCertDefinitions.test.js ├── getCert.js ├── getCert.test.js ├── obtainCert.js ├── obtainCert.test.js ├── outputInfo.js ├── syncCerts.js ├── syncCerts.test.js ├── syncPeriodically.js ├── syncPeriodically.test.js └── testCmd.js ├── clientPermittedAccessToCerts.js ├── clientPermittedAccessToCerts.test.js ├── execCommand.js ├── execCommand.test.js ├── generateFirstCertInSequence.js ├── generateFirstCertInSequence.test.js ├── getArgv.js ├── getCertInfoFromPath.js ├── getCertInfoFromPath.test.js ├── getCertInfoFromPem.js ├── getConfig.js ├── getConfig.test.js ├── getExtensions.js ├── getExtensions.test.js ├── getExtensionsForDomains.js ├── getExtensionsForDomains.test.js ├── getLocalCertificates.js ├── getLocalCertificates.test.js ├── getMetaFromExtensionFunction.js ├── helpers ├── allItemsPresent.js ├── arrayItemsMatch.js ├── arrayItemsMatch.test.js ├── concurrencyLimiter.js ├── concurrencyLimiter.test.js ├── fileExists.js ├── fileExists.test.js ├── filterAsync.js ├── filterAsync.test.js ├── metaItemsMatch.js ├── metaItemsMatch.test.js ├── mkdirRecursive.js ├── mkdirRecursive.test.js ├── requireModule.js ├── setAndDemandDirPerms.js ├── setAndDemandDirPerms.test.js ├── setTimeoutPromise.js ├── setTimeoutPromise.test.js ├── someAsync.js ├── someAsync.test.js ├── sortObjectProperties.js └── sortObjectProperties.test.js ├── httpRedirect.js ├── httpRedirect.test.js ├── listCerts.js ├── loadCert.js ├── loadCert.test.js ├── normalizeMeta.js ├── normalizeMeta.test.js ├── regexps └── reDefinition.js ├── request.js ├── request.test.js ├── server ├── actions │ ├── __snapshots__ │ │ └── getInfo.test.js.snap │ ├── getCert.js │ ├── getCert.test.js │ ├── getInfo.js │ ├── getInfo.test.js │ └── index.js ├── createRequestHandler.js ├── serve.js └── serve.test.js ├── writeBundle.js └── writeBundle.test.js /.dockerignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | catkeys 3 | certs 4 | certbot 5 | conf 6 | Dockerfile 7 | docs 8 | node_modules 9 | venv 10 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: https://EditorConfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | # Unix-style newlines with a newline ending every file 7 | [*] 8 | end_of_line = lf 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | charset = utf-8 12 | indent_style = space 13 | indent_size = 2 14 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: true, 4 | commonjs: true, 5 | es6: true 6 | }, 7 | extends: [ 8 | 'standard' 9 | ], 10 | globals: { 11 | Atomics: 'readonly', 12 | SharedArrayBuffer: 'readonly' 13 | }, 14 | parserOptions: { 15 | ecmaVersion: 2018 16 | }, 17 | rules: { 18 | 'max-len': ["error", { "code": 80 }] 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /.github/workflows/node.js.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Node.js CI 5 | 6 | on: 7 | push: 8 | branches: [ master ] 9 | pull_request: 10 | branches: [ master ] 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | strategy: 16 | matrix: 17 | node-version: [12.x, 14.x] 18 | steps: 19 | - uses: actions/checkout@v2 20 | - name: Use Node.js ${{ matrix.node-version }} 21 | uses: actions/setup-node@v1 22 | with: 23 | node-version: ${{ matrix.node-version }} 24 | - name: Install certbot 25 | run: >- 26 | sudo apt-get install -y -qq python3 unzip && 27 | sudo pip3 install -r docker/requirements.txt 28 | - name: Install ngrok 29 | run: sudo unzip sit/deps/ngrok-stable-linux-amd64.zip -d /usr/local/bin 30 | - run: npm i 31 | - name: Run tests 32 | run: sudo CERTCACHE_CERTBOT_EMAIL=tm_certcache-sit@93m.org npm test 33 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.vscode 3 | /cache 4 | /catkeys 5 | /certs 6 | /conf 7 | /coverage 8 | /docker-compose/certcacheclient/certcache/catkeys 9 | /docker-compose/certcacheclient/certcache/certs 10 | /node_modules 11 | /package-lock.json 12 | /sit/coverage 13 | /sit/skel/server/cache/thirdparty/ 14 | /sit/test 15 | /venv 16 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:12.16.0-alpine3.11 as deps 2 | 3 | RUN apk update && \ 4 | apk add --no-cache openssl python3 && \ 5 | rm -rf /var/cache/apk/* 6 | 7 | FROM deps as certbot-build 8 | 9 | COPY ./docker/requirements.txt /certbot/requirements.txt 10 | 11 | WORKDIR /certbot/ 12 | 13 | ENV RUSTUP_HOME=/usr/local/rustup \ 14 | CARGO_HOME=/usr/local/cargo \ 15 | PATH=/usr/local/cargo/bin:$PATH \ 16 | RUST_VERSION=1.50.0 17 | 18 | RUN set -eux; \ 19 | apkArch="$(apk --print-arch)"; \ 20 | case "$apkArch" in \ 21 | x86_64) rustArch='x86_64-unknown-linux-musl'; rustupSha256='05c5c05ec76671d73645aac3afbccf2187352fce7e46fc85be859f52a42797f6' ;; \ 22 | aarch64) rustArch='aarch64-unknown-linux-musl'; rustupSha256='6a8a480d8d9e7f8c6979d7f8b12bc59da13db67970f7b13161ff409f0a771213' ;; \ 23 | *) echo >&2 "unsupported architecture: $apkArch"; exit 1 ;; \ 24 | esac; \ 25 | url="https://static.rust-lang.org/rustup/archive/1.23.1/${rustArch}/rustup-init"; \ 26 | wget "$url"; \ 27 | echo "${rustupSha256} *rustup-init" | sha256sum -c -; \ 28 | chmod +x rustup-init; \ 29 | ./rustup-init -y --no-modify-path --profile minimal --default-toolchain $RUST_VERSION --default-host ${rustArch}; \ 30 | rm rustup-init; \ 31 | chmod -R a+w $RUSTUP_HOME $CARGO_HOME; \ 32 | rustup --version; \ 33 | cargo --version; \ 34 | rustc --version; \ 35 | apk add bash gcc python3-dev libffi-dev openssl-dev musl-dev ca-certificates; \ 36 | pip3 install virtualenv; \ 37 | virtualenv venv; \ 38 | bash -c ". /certbot/venv/bin/activate && pip install -r /certbot/requirements.txt" 39 | 40 | FROM node:12.16.0-alpine3.11 as certcache-build-deps 41 | 42 | RUN apk update && apk add g++ make git 43 | 44 | FROM certcache-build-deps as certcache-build 45 | 46 | COPY src /certcachesrc/src 47 | COPY package.json /certcachesrc/package.json 48 | 49 | ENV NODE_ENV=production 50 | 51 | RUN npm install --production -g /certcachesrc/ 52 | 53 | FROM deps as dist-test 54 | 55 | WORKDIR /certcachesrc/ 56 | 57 | COPY --from=certcache-build /certcachesrc /certcachesrc 58 | COPY --from=certbot-build /certbot/venv /certbot/venv 59 | COPY sit /certcachesrc/sit 60 | COPY jest.config.all.js /certcachesrc/jest.config.all.js 61 | COPY jest.config.js /certcachesrc/jest.config.js 62 | COPY jest.config.sit.js /certcachesrc/jest.config.sit.js 63 | 64 | RUN apk add bash unzip && \ 65 | npm install && \ 66 | unzip /certcachesrc/sit/deps/ngrok-stable-linux-amd64.zip -d /usr/local/bin && \ 67 | bash -c ". /certbot/venv/bin/activate && CERTCACHE_CERTBOT_EMAIL=tm_certcache-sit@93m.org npm test" 68 | 69 | FROM deps as dist 70 | 71 | WORKDIR /certcache/ 72 | 73 | COPY --from=certcache-build /certcachesrc /usr/local/lib/node_modules/certcache 74 | COPY --from=certbot-build /certbot/venv /certbot/venv 75 | COPY docker/entrypoint.sh /entrypoint.sh 76 | 77 | RUN ln -s /usr/local/lib/node_modules/certcache/src/cli/cli.js \ 78 | /usr/local/bin/certcache && \ 79 | chmod +x /entrypoint.sh 80 | 81 | VOLUME /certcache/bin/ 82 | VOLUME /certcache/cache/ 83 | VOLUME /certcache/catkeys/ 84 | VOLUME /certcache/certs/ 85 | VOLUME /certcache/conf/ 86 | VOLUME /certcache/credentials/ 87 | 88 | EXPOSE 53 89 | EXPOSE 80 90 | EXPOSE 4433 91 | 92 | ENTRYPOINT ["/entrypoint.sh"] 93 | 94 | CMD ["client"] 95 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 93 Million Ltd. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /docker-compose/certcacheclient/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.7' 2 | services: 3 | certcache: 4 | container_name: certcache 5 | image: ghcr.io/93million/certcache 6 | restart: 'unless-stopped' 7 | volumes: 8 | - ./certcache/catkeys/:/certcache/catkeys/:rw 9 | - ./certcache/certs/:/certcache/certs/:rw 10 | environment: 11 | CERTCACHE_UPSTREAM: 12 | CERTCACHE_CERTS: | 13 | - certName: web 14 | domains: 15 | - '' 16 | - '*.' 17 | - '' 18 | - '*.' 19 | nginx: 20 | container_name: nginx 21 | image: nginx 22 | restart: 'unless-stopped' 23 | volumes: 24 | - ./certcache/certs/:/etc/certcache/certs/:ro 25 | - ./nginx/config/:/etc/nginx/conf.d/:ro 26 | - ./www/:/var/www/:ro 27 | ports: 28 | - '80:80' 29 | - '443:443' 30 | -------------------------------------------------------------------------------- /docker-compose/certcacheclient/nginx/config/000-default.conf: -------------------------------------------------------------------------------- 1 | server { 2 | listen 443 ssl http2 default_server; 3 | listen [::]:443 ssl http2 default_server; 4 | ssl_certificate /etc/certcache/certs/web/fullchain.pem; 5 | ssl_certificate_key /etc/certcache/certs/web/privkey.pem; 6 | 7 | server_name _; 8 | 9 | root /var/www/; 10 | } 11 | 12 | server { 13 | listen 80 default_server; 14 | listen [::]:80; 15 | 16 | server_name _; 17 | 18 | location / { 19 | return 301 https://$host$request_uri; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /docker-compose/certcacheclient/www/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | CertCache test 5 | 6 | 7 |

CertCache

8 | 9 | 10 | -------------------------------------------------------------------------------- /docker-compose/certcacheserver/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.7' 2 | services: 3 | certcacheserver: 4 | container_name: certcacheserver 5 | image: ghcr.io/93million/certcache 6 | restart: unless-stopped 7 | ports: 8 | - '53:53/udp' 9 | - '53:53/tcp' 10 | - '80:80/tcp' 11 | - '4433:4433/tcp' 12 | volumes: 13 | - ./catkeys/:/certcache/catkeys/:rw 14 | - ./cache/:/certcache/cache/:rw 15 | environment: 16 | CERTCACHE_CERTBOT_EMAIL: 17 | command: ['serve'] 18 | -------------------------------------------------------------------------------- /docker-compose/standalone/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.7' 2 | services: 3 | certcache: 4 | container_name: certcache 5 | restart: unless-stopped 6 | image: ghcr.io/93million/certcache 7 | ports: 8 | - '53:53/udp' 9 | - '53:53/tcp' 10 | volumes: 11 | - ./certcache/cache:/certcache/cache:rw 12 | - ./certcache/catkeys:/certcache/catkeys:ro 13 | - ./certcache/certs:/certcache/certs:rw 14 | environment: 15 | CERTCACHE_CERTBOT_EMAIL: 16 | CERTCACHE_CERTS: | 17 | - certName: web 18 | domains: 19 | - '' 20 | - '*.' 21 | nginx: 22 | container_name: nginx 23 | restart: unless-stopped 24 | image: nginx 25 | ports: 26 | - '80:80' 27 | - '443:443' 28 | volumes: 29 | - ./certcache/certs/:/etc/certcache/certs/:ro 30 | - ./nginx/config/:/etc/nginx/conf.d/:ro 31 | - ./www/:/var/www/:ro 32 | command: "/bin/sh -c 'while :; do sleep 6h & wait $${!}; nginx -s reload; done & nginx -g \"daemon off;\"'" 33 | -------------------------------------------------------------------------------- /docker-compose/standalone/nginx/config/000-default.conf: -------------------------------------------------------------------------------- 1 | server { 2 | listen 443 ssl http2 default_server; 3 | listen [::]:443 ssl http2 default_server; 4 | ssl_certificate /etc/certcache/certs/web/fullchain.pem; 5 | ssl_certificate_key /etc/certcache/certs/web/privkey.pem; 6 | 7 | server_name _; 8 | 9 | root /var/www/; 10 | } 11 | 12 | server { 13 | listen 80 default_server; 14 | listen [::]:80; 15 | 16 | server_name _; 17 | 18 | location / { 19 | return 301 https://$host$request_uri; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /docker-compose/standalone/www/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | CertCache test 5 | 6 | 7 |

CertCache

8 | 9 | 10 | -------------------------------------------------------------------------------- /docker/entrypoint.sh: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env sh 2 | 3 | stop() { 4 | kill -s TERM $NODE_PID 5 | } 6 | 7 | trap stop TERM 8 | 9 | . /certbot/venv/bin/activate 10 | certcache $@ & 11 | NODE_PID=$! 12 | wait $NODE_PID 13 | -------------------------------------------------------------------------------- /docker/requirements.txt: -------------------------------------------------------------------------------- 1 | acme==1.13.0 2 | certbot==1.13.0 3 | certbot-dns-cloudflare==1.13.0 4 | certbot-dns-cloudxns==1.13.0 5 | certbot-dns-digitalocean==1.13.0 6 | certbot-dns-dnsimple==1.13.0 7 | certbot-dns-dnsmadeeasy==1.13.0 8 | certbot-dns-google==1.13.0 9 | certbot-dns-linode==1.13.0 10 | certbot-dns-luadns==1.13.0 11 | certbot-dns-nsone==1.13.0 12 | certbot-dns-ovh==1.13.0 13 | certbot-dns-rfc2136==1.13.0 14 | certbot-dns-route53==1.13.0 15 | certbot-dns-standalone==1.0.3 16 | zope.interface>=5.3.0a1 17 | httplib2>=0.15.0 18 | -------------------------------------------------------------------------------- /docs/Debugging problems.md: -------------------------------------------------------------------------------- 1 | # Debugging problems 2 | 3 | ## Testing connection from client to server 4 | 5 | After generating keys, and starting up your certcache server, you can test connection from the client to the server by running this command the directory your client's `docker-compose.yml` is in: 6 | 7 | ``` 8 | docker-compose run --rm certcache test 9 | ``` 10 | 11 | Once you have server and client configured correctly you should see a confirmation message like this: 12 | 13 | ``` 14 | $ docker-compose run --rm certcache test 15 | Connected sucessfully to server certcache.93million.org:4433 running version 0.1.0-beta.0 16 | ``` 17 | 18 | ## Viewing logs 19 | 20 | If there were errors obtaining certicates there will be log entries in the client and the server. 21 | 22 | To view client logs, run `docker-compose logs certcache` from the client. The client logs will only tell you if there were problems retrieving the certificate from the server but won't go into detail. 23 | 24 | Viewing logs on the server will provide more information. From the server, run `docker-compose logs certcacheserver` 25 | 26 | ### Increasing log verbosity 27 | 28 | By default the server logs are not very verbose. CertCache uses the npm `debug` package. Viewing debug messages will give further information. Set the env var `DEBUG` to `certcache:*` in the `environment` section of the `certcacheserver` container in your `docker-compose.yml` file: 29 | 30 | ```yaml 31 | version: '3.7' 32 | services: 33 | certcacheserver: 34 | container_name: certcacheserver 35 | image: ghcr.io/93million/certcache 36 | … 37 | environment: 38 | DEBUG: certcache:* 39 | ``` 40 | 41 | Future logs should contain extra information to help debug problems. 42 | 43 | ## Viewing certificates in the server cache 44 | 45 | It can be useful to view certificates in the server cache. It can give you an idea whether a problem you are experiencing lies in the generation of cert on the server, or in the deployment of certificates from the server to the client. 46 | 47 | From the server, run `docker-compose run --rm certcacheserver ls` 48 | 49 | This will list certbot and thirdparty certificates in the server cache. 50 | -------------------------------------------------------------------------------- /docs/ECDSA Certificates.md: -------------------------------------------------------------------------------- 1 | # ECDSA Certificates 2 | 3 | ## Using ECDSA for certs defined in `CERTCACHE_CERTS` 4 | 5 | CertCache supports generating and caching of ECDSA certificates. 6 | 7 | ECDSA algorithms can be requested for each cert defined in `CERTCACHE_CERTS` separately: 8 | 9 | ```yaml 10 | version: '3.7' 11 | services: 12 | certcache: 13 | container_name: certcache 14 | … 15 | CERTCACHE_CERTS: | 16 | - certName: cert1 17 | domains: 18 | - '' 19 | - '*.' 20 | keyType: ecdsa 21 | - certName: cert2 22 | domains: 23 | - '' 24 | - '*.' 25 | ``` 26 | 27 | In this example, `cert1` will have an ECDSA public key algorithm, while `cert2` will use the default algorithm of RSA. 28 | 29 | If you want to use ECDSA for all certificates that do not specify a `keyType`, set the default algorithm using the env var `CERTCACHE_KEY_TYPE`: 30 | 31 | ```yaml 32 | version: '3.7' 33 | services: 34 | certcache: 35 | container_name: certcache 36 | … 37 | CERTCACHE_CERTS: | 38 | - certName: cert1 39 | domains: 40 | - '' 41 | - '*.' 42 | - certName: cert2 43 | domains: 44 | - '' 45 | - '*.' 46 | CERTCACHE_KEY_TYPE: ecdsa 47 | ``` 48 | 49 | The default curve is `secp256r1`. The elliptic curve can be defined separately within each certificate within `CERTCACHE_CERTS`, or changed for all certs (that don't define an `ellipticCurve`) using the env var `CERTCACHE_ELLIPTIC_CURVE`: 50 | 51 | ```yaml 52 | version: '3.7' 53 | services: 54 | certcache: 55 | container_name: certcache 56 | … 57 | CERTCACHE_CERTS: | 58 | - certName: cert1 59 | domains: 60 | - '' 61 | - '*.' 62 | keyType: ecdsa 63 | - certName: cert2 64 | domains: 65 | - '' 66 | - '*.' 67 | keyType: ecdsa 68 | ellipticCurve: secp256r1 69 | CERTCACHE_ELLIPTIC_CURVE: secp384r1 70 | ``` 71 | 72 | ## Using ECDSA for certs retrieved from the command line 73 | 74 | You can get ECDSA certificates from the command line using the CLI command `certcache get -d 'cert-domain-1,cert-domain-2' --key-type ecdsa`: 75 | 76 | If using docker-compose: 77 | 78 | ``` 79 | docker-compose run --rm certcache get -d 'cert-domain-1,cert-domain-2' --key-type ecdsa 80 | ``` 81 | 82 | Curves can optionally be specified using `--elliptic-curve`. 83 | -------------------------------------------------------------------------------- /docs/Using certificates.md: -------------------------------------------------------------------------------- 1 | # Using certificates from other apps 2 | 3 | Certificates are installed into `/certcache/certs/` in the CertCache client container. Map a Docker volume like so: 4 | 5 | ```yaml 6 | version: '3.7' 7 | services: 8 | certcache: 9 | container_name: certcache 10 | image: ghcr.io/93million/certcache 11 | … 12 | volumes: 13 | - ./certcache/certs/:/certcache/certs/:rw 14 | … 15 | ``` 16 | 17 | Certs are stored in the format used by Certbot (regardless of whether they are generated by Certbot or returned by the thirdparty extension). 18 | 19 | ``` 20 | $ find certs 21 | certs 22 | certs/93m.co 23 | certs/93m.co/chain.pem 24 | certs/93m.co/cert.pem 25 | certs/93m.co/privkey.pem 26 | certs/93m.co/fullchain.pem 27 | ``` 28 | 29 | Simply map the directory through to other containers that need access to the certificates. Eg: 30 | 31 | ```yaml 32 | nginx: 33 | container_name: nginx 34 | image: nginx 35 | volumes: 36 | - ./certcache/certs/:/etc/certcache/certs/:ro 37 | - ./nginx/config/:/etc/nginx/conf.d/:rw 38 | - 39 | ``` 40 | 41 | You can then reference the certificates from your container. Eg: 42 | 43 | ``` 44 | server { 45 | listen 443 ssl http2 default_server; 46 | listen [::]:443 ssl http2 default_server; 47 | ssl_certificate /etc/certcache/certs/93m.co/fullchain.pem; 48 | ssl_certificate_key /etc/certcache/certs/93m.co/privkey.pem; 49 | 50 | server_name 93m.co; 51 | 52 | root /var/www; 53 | } 54 | ``` 55 | -------------------------------------------------------------------------------- /jest.config.all.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | clearMocks: true, 3 | coveragePathIgnorePatterns: ['/node_modules/', '/sit/'], 4 | testEnvironment: 'node', 5 | testTimeout: 120 * 1000 6 | } 7 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | clearMocks: true, 3 | testEnvironment: 'node', 4 | testPathIgnorePatterns: ['sit'] 5 | } 6 | -------------------------------------------------------------------------------- /jest.config.sit.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | rootDir: 'sit', 3 | testEnvironment: 'node', 4 | testTimeout: 120 * 1000 5 | } 6 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "certcache", 3 | "version": "0.6.0", 4 | "description": "TLS certificate server that generates, caches and deploys SSL/TLS certificates", 5 | "main": "index.js", 6 | "repository": "https://github.com/93million/certcache", 7 | "bin": "src/cli/cli.js", 8 | "scripts": { 9 | "cli": "node src/cli/cli.js", 10 | "clidev": "nodemon --inspect -- src/cli/cli.js", 11 | "lint": "eslint .", 12 | "start": "node src/cli/cli.js serve", 13 | "startdev": "nodemon --inspect src/cli/cli.js serve", 14 | "test": "jest -c jest.config.all.js --coverage", 15 | "test:sit": "jest -c jest.config.sit.js", 16 | "test:unit": "jest -c jest.config.js --coverage" 17 | }, 18 | "author": "Pommy (https://93million.org)", 19 | "license": "MIT", 20 | "files": [ 21 | "/src" 22 | ], 23 | "dependencies": { 24 | "@fidm/x509": "^1.2.1", 25 | "catkeys": "^1.2.0", 26 | "debug": "^4.1.1", 27 | "md5": "^2.2.1", 28 | "node-rsa": "^1.0.8", 29 | "rimraf": "^2.6.3", 30 | "tar-stream": "^2.1.2", 31 | "yaml": "^1.9.2", 32 | "yargs": "^15.4.1" 33 | }, 34 | "devDependencies": { 35 | "eslint": "^6.8.0", 36 | "eslint-config-standard": "^13.0.1", 37 | "eslint-plugin-import": "^2.20.2", 38 | "eslint-plugin-node": "^9.2.0", 39 | "eslint-plugin-promise": "^4.2.1", 40 | "eslint-plugin-standard": "^4.0.1", 41 | "fs-extra": "^8.1.0", 42 | "husky": "^4.2.5", 43 | "jest": "^24.9.0", 44 | "lint-staged": "^9.5.0", 45 | "nodemon": "^2.0.4" 46 | }, 47 | "jest": { 48 | "testEnvironment": "node" 49 | }, 50 | "husky": { 51 | "hooks": { 52 | "pre-commit": "lint-staged" 53 | } 54 | }, 55 | "lint-staged": { 56 | "*.js": [ 57 | "npm run lint" 58 | ] 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /sit/bin/cnf/ca.cnf: -------------------------------------------------------------------------------- 1 | [ ca ] 2 | default_ca = CA_default 3 | 4 | [ CA_default ] 5 | serial = ca-serial 6 | crl = ca-crl.pem 7 | database = ca-database.txt 8 | name_opt = CA_default 9 | cert_opt = CA_default 10 | default_crl_days = 9999 11 | default_md = md5 12 | 13 | [ req ] 14 | default_bits = 4096 15 | days = 9999 16 | distinguished_name = req_distinguished_name 17 | attributes = req_attributes 18 | prompt = no 19 | 20 | [ req_distinguished_name ] 21 | countryName = GB 22 | stateOrProvinceName = Tyne and Wear 23 | localityName = Newcastle upon Tyne 24 | organizationName = clientAuthenticatedHttps 25 | organizationalUnitName = clientAuthenticatedHttps 26 | commonName = clientAuthenticatedHttps 27 | emailAddress = certcache@example.com 28 | 29 | 30 | [ req_attributes ] 31 | challengePassword = test 32 | -------------------------------------------------------------------------------- /sit/bin/createca.sh: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env bash 2 | 3 | set -e 4 | 5 | DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )" 6 | 7 | while getopts ":n:d:" o; do 8 | case "${o}" in 9 | d) 10 | OUTPUT_DIR="${OPTARG}" 11 | ;; 12 | esac 13 | done 14 | shift $((OPTIND-1)) 15 | 16 | mkdir -p "$OUTPUT_DIR" 17 | 18 | openssl req \ 19 | -new \ 20 | -x509 \ 21 | -days 9999 \ 22 | -config "$DIR/cnf/ca.cnf" \ 23 | -keyout "$OUTPUT_DIR/ca-key.pem" \ 24 | -out "$OUTPUT_DIR/ca-crt.pem" \ 25 | -nodes \ 26 | 2> /dev/null 27 | -------------------------------------------------------------------------------- /sit/bin/createcert.sh: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env bash 2 | 3 | set -e 4 | 5 | DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )" 6 | 7 | cleanup () { 8 | rm -f "$OUTPUT_DIR/$COMMON_NAME/csr.pem" 9 | } 10 | 11 | trap cleanup SIGHUP SIGINT EXIT 12 | 13 | while getopts ":n:d:" o; do 14 | case "${o}" in 15 | n) 16 | COMMON_NAME="${OPTARG}" 17 | ;; 18 | d) 19 | OUTPUT_DIR="${OPTARG}" 20 | ;; 21 | esac 22 | done 23 | shift $((OPTIND-1)) 24 | 25 | mkdir -p "$OUTPUT_DIR/$COMMON_NAME" 26 | openssl genrsa -out "$OUTPUT_DIR/$COMMON_NAME/privkey.pem" 4096 \ 27 | 2> /dev/null 28 | openssl req \ 29 | -new \ 30 | -subj "/C=GB/ST=Tyne and Wear/L=Newcastle upon Tyne/O=clientAuthenticatedHttps/OU=clientAuthenticatedHttps/CN=$COMMON_NAME" \ 31 | -key "$OUTPUT_DIR/$COMMON_NAME/privkey.pem" \ 32 | -out "$OUTPUT_DIR/$COMMON_NAME/csr.pem" \ 33 | 2> /dev/null 34 | openssl x509 \ 35 | -req \ 36 | -days 9999 \ 37 | -in "$OUTPUT_DIR/$COMMON_NAME/csr.pem" \ 38 | -CA "$OUTPUT_DIR/ca-crt.pem" \ 39 | -CAkey "$OUTPUT_DIR/ca-key.pem" \ 40 | -CAcreateserial \ 41 | -CAserial "$OUTPUT_DIR/.srl" \ 42 | -out "$OUTPUT_DIR/$COMMON_NAME/cert.pem" \ 43 | 2> /dev/null 44 | -------------------------------------------------------------------------------- /sit/deps/ngrok-stable-linux-amd64.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/93million/certcache/7bf7461fbc82a24f619e75ea2a3521c74f2fd3d8/sit/deps/ngrok-stable-linux-amd64.zip -------------------------------------------------------------------------------- /sit/filepaths.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | 3 | const cliCmd = path.resolve(__dirname, '..', 'src', 'cli', 'cli.js') 4 | const testSkelDir = path.resolve(__dirname, 'skel') 5 | const testDir = path.resolve(__dirname, 'test') 6 | const testServerDir = path.resolve(testDir, 'server') 7 | const testClientDir = path.resolve(testDir, 'client') 8 | const testServerCatkeysDir = path.resolve(testServerDir, 'catkeys') 9 | const testClientCatkeysDir = path.resolve(testClientDir, 'catkeys') 10 | const testStandaloneDir = path.resolve(testDir, 'standalone') 11 | 12 | module.exports = { 13 | cliCmd, 14 | testClientCatkeysDir, 15 | testClientDir, 16 | testDir, 17 | testServerCatkeysDir, 18 | testServerDir, 19 | testSkelDir, 20 | testStandaloneDir 21 | } 22 | -------------------------------------------------------------------------------- /sit/lib/setupTests.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const { promisify } = require('util') 3 | const fse = require('fs-extra') 4 | const childProcess = require('child_process') 5 | const { 6 | cliCmd, 7 | testClientCatkeysDir, 8 | testDir, 9 | testServerCatkeysDir, 10 | testServerDir, 11 | testSkelDir, 12 | testStandaloneDir 13 | } = require('../filepaths') 14 | const startNgrok = require('./startNgrok') 15 | 16 | const execFile = promisify((cmd, a2, a3, a4) => { 17 | const args = (Array.isArray(a2)) ? a2 : [] 18 | const callback = [a2, a3, a4].find((arg) => (typeof arg === 'function')) 19 | 20 | childProcess.execFile(cmd, args, (err, stdin, stderr) => { 21 | if (callback !== undefined) { 22 | callback(err, stdin) 23 | } 24 | }) 25 | }) 26 | 27 | module.exports = async () => { 28 | // delete testing dir 29 | await fse.emptyDir(testDir) 30 | await fse.copy(testSkelDir, testDir) 31 | // // create authentication keys 32 | await execFile( 33 | cliCmd, 34 | ['create-keys', '--catkeys', testServerCatkeysDir] 35 | ) 36 | 37 | // copy client key to certcache client 38 | await fse.copy( 39 | path.resolve(testServerCatkeysDir, 'client.catkey'), 40 | path.resolve(testClientCatkeysDir, 'client.catkey') 41 | ) 42 | 43 | // create test certs 44 | await execFile( 45 | path.resolve(__dirname, '..', 'bin', 'createca.sh'), 46 | ['-d', path.resolve(testServerDir, 'cache', 'thirdparty')] 47 | ) 48 | await execFile( 49 | path.resolve(__dirname, '..', 'bin', 'createcert.sh'), 50 | [ 51 | '-n', 52 | 'test.example.com', 53 | '-d', 54 | path.resolve(testServerDir, 'cache', 'thirdparty') 55 | ] 56 | ) 57 | await execFile( 58 | path.resolve(__dirname, '..', 'bin', 'createcert.sh'), 59 | [ 60 | '-n', 61 | 'foo.example.com', 62 | '-d', 63 | path.resolve(testServerDir, 'cache', 'thirdparty') 64 | ] 65 | ) 66 | await execFile( 67 | path.resolve(__dirname, '..', 'bin', 'createca.sh'), 68 | ['-d', path.resolve(testStandaloneDir, 'cache', 'thirdparty')] 69 | ) 70 | await execFile( 71 | path.resolve(__dirname, '..', 'bin', 'createcert.sh'), 72 | [ 73 | '-n', 74 | 'standalone.example.com', 75 | '-d', 76 | path.resolve(testStandaloneDir, 'cache', 'thirdparty') 77 | ] 78 | ) 79 | 80 | // start ngrok 81 | const { info: ngrok, process: ngrokProcess } = await startNgrok([ 82 | 'http', 83 | '80' 84 | ]) 85 | 86 | // start certcache server 87 | const serveProcess = childProcess.execFile( 88 | cliCmd, 89 | ['serve', '--catkeys', testServerCatkeysDir], 90 | { cwd: testServerDir } 91 | ) 92 | 93 | serveProcess.stderr.on('data', (data) => { 94 | console.error('CertCache server error:', data.toString()) 95 | }) 96 | 97 | await new Promise((resolve) => setTimeout(resolve, 500)) 98 | 99 | return { 100 | cleanup: async () => { 101 | serveProcess.kill() 102 | ngrokProcess.kill() 103 | await fse.emptyDir(testDir) 104 | }, 105 | ngrok 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /sit/lib/startNgrok.js: -------------------------------------------------------------------------------- 1 | const childProcess = require('child_process') 2 | const http = require('http') 3 | 4 | module.exports = (args) => { 5 | return new Promise((resolve) => { 6 | const process = childProcess.execFile('ngrok', args) 7 | let tryGetNgrokTunnelTimeout 8 | const rejectTimeoutSeconds = 10 9 | const tryGetNgrokTunnel = () => { 10 | http 11 | .get( 12 | 'http://localhost:4040/api/tunnels/', 13 | (res) => { 14 | res.setEncoding('utf8') 15 | if (res.statusCode === 200) { 16 | clearTimeout(rejectTimeout) 17 | const chunks = [] 18 | res.on('data', (chunk) => { 19 | chunks.push(chunk) 20 | }) 21 | res.on('end', () => { 22 | const tunnels = JSON.parse(chunks.join()).tunnels 23 | 24 | if (tunnels.length === 2) { 25 | resolve({ info: { tunnels }, process }) 26 | } else { 27 | tryGetNgrokTunnelTimeout = setTimeout(tryGetNgrokTunnel, 300) 28 | } 29 | }) 30 | } else { 31 | tryGetNgrokTunnelTimeout = setTimeout(tryGetNgrokTunnel, 300) 32 | } 33 | } 34 | ) 35 | .on('error', (e) => { 36 | tryGetNgrokTunnelTimeout = setTimeout(tryGetNgrokTunnel, 300) 37 | }) 38 | } 39 | const rejectTimeout = setTimeout( 40 | () => { 41 | clearTimeout(tryGetNgrokTunnelTimeout) 42 | throw new Error( 43 | `Ngrok took longer than ${rejectTimeoutSeconds} seconds to start` 44 | ) 45 | }, 46 | rejectTimeoutSeconds * 1000 47 | ) 48 | 49 | tryGetNgrokTunnel() 50 | }) 51 | } 52 | -------------------------------------------------------------------------------- /sit/skel/server/conf/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "extensions": { 3 | "certbot": { 4 | "domains": [ 5 | { 6 | "domain": "~(.*\\.)?.ngrok.io$", 7 | "challenges": ["http-01"] 8 | } 9 | ] 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/bin/generate-access-keys.sh: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env bash 2 | 3 | # TODO remove this 4 | 5 | # set -e 6 | 7 | # DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )" 8 | 9 | # SERVER_NAME="$1" 10 | 11 | # usage () { 12 | # echo "$0" "" 13 | # } 14 | 15 | # if [ -z "$SERVER_NAME" ]; then 16 | # usage 17 | # exit 1 18 | # fi 19 | 20 | # "$DIR/../lib/clientAuthenticatedHttps/bin/create-server-key.sh" -k "$DIR/../../catkeys" -n "$SERVER_NAME" 21 | # "$DIR/../lib/clientAuthenticatedHttps/bin/create-client-key.sh" -k "$DIR/../../catkeys" -n "client" 22 | -------------------------------------------------------------------------------- /src/cli/cli.js: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env node 2 | 3 | const yargs = require('yargs') 4 | const commands = require('./commands') 5 | const getExtensions = require('../lib/getExtensions') 6 | 7 | const addCommandGroup = (command, group) => { 8 | return Object.keys(command).reduce( 9 | (acc, cmd) => { 10 | return { 11 | ...acc, 12 | [cmd]: { ...command[cmd], group } 13 | } 14 | }, 15 | {} 16 | ) 17 | } 18 | 19 | const handleExec = async () => { 20 | const extensions = await getExtensions() 21 | 22 | // eslint-disable-next-line 23 | Object 24 | .keys(commands) 25 | .reduce( 26 | (acc, key) => { 27 | const { cmd, desc, handler } = commands[key] 28 | let builder = commands[key].builder 29 | 30 | Object.keys(extensions).forEach((extension) => { 31 | const { commandArgs = {} } = extensions[extension] 32 | 33 | if (commandArgs[key] !== undefined) { 34 | const extendedArgs = addCommandGroup( 35 | commandArgs[key], 36 | `Extension: ${extension}` 37 | ) 38 | 39 | builder = { ...extendedArgs, ...builder } 40 | } 41 | }) 42 | 43 | const args = [ 44 | cmd, 45 | desc, 46 | ...[builder, handler].filter((arg) => (arg !== undefined)) 47 | ] 48 | 49 | acc.command(...args) 50 | 51 | return acc 52 | }, 53 | yargs 54 | ) 55 | .demandCommand() 56 | .strict() 57 | .argv 58 | } 59 | 60 | handleExec() 61 | -------------------------------------------------------------------------------- /src/cli/commands/add-client.js: -------------------------------------------------------------------------------- 1 | const childProcess = require('child_process') 2 | const path = require('path') 3 | const util = require('util') 4 | const { catkeys } = require('./args') 5 | 6 | const execFile = util.promisify(childProcess.execFile) 7 | 8 | module.exports = { 9 | cmd: 'add-client [name]', 10 | desc: 11 | 'Create access keys to allow certcache clients to access certcache server', 12 | builder: (yargs) => { 13 | yargs.positional('name', { 14 | describe: 'Name of the client key', 15 | required: true 16 | }) 17 | yargs.option('catkeys', catkeys) 18 | }, 19 | handler: (argv) => { 20 | const execScript = path.resolve( 21 | __dirname, 22 | '..', 23 | '..', 24 | '..', 25 | 'node_modules', 26 | '.bin', 27 | 'catkeys' 28 | ) 29 | 30 | execFile( 31 | execScript, 32 | ['create-key', '--keydir', argv.catkeys, '--name', argv.name] 33 | ) 34 | .catch((err) => { 35 | console.error(err) 36 | }) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/cli/commands/args/index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | catkeys: { 3 | alias: 'k', 4 | description: 5 | 'Path to catkeys directory. Alternativly use env CERTCACHE_CAH_KEYS_DIR' 6 | }, 7 | days: { 8 | description: 'Number of days to renew certificate before expiry' 9 | }, 10 | ellipticCurve: { 11 | description: 12 | 'Curve to use when key type is ecdsa. See RFC 8446 for supported values.' 13 | }, 14 | httpRedirectUrl: { 15 | description: [ 16 | 'Address of a Certcache server to redirect challenges to when Certcache', 17 | 'client server is recipient of HTTP-01 ACME challenges' 18 | ].join(' ') 19 | }, 20 | keyType: { 21 | description: 'Type of key to search for (either ecdsa or rsa)' 22 | }, 23 | maxRequestTime: { 24 | description: 25 | 'Maximum time (in minutes) requests to CertCache server should take' 26 | }, 27 | skipFilePerms: { 28 | boolean: true, 29 | description: 30 | 'Don\'t test or set directory file permissions when writing certificates' 31 | }, 32 | upstream: { 33 | alias: 'u', 34 | description: [ 35 | 'Upstream hostname of upstream Certcache Server.', 36 | 'Include port in format :' 37 | ].join(' ') 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/cli/commands/client.js: -------------------------------------------------------------------------------- 1 | const sync = require('./sync') 2 | const syncPeriodically = require('../../lib/client/syncPeriodically') 3 | 4 | const { forever, ...builder } = sync.builder 5 | 6 | module.exports = { 7 | cmd: 'client', 8 | desc: `${forever.description} (aliases 'sync --forever')`, 9 | builder, 10 | handler: async () => { 11 | await syncPeriodically(true) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/cli/commands/create-keys.js: -------------------------------------------------------------------------------- 1 | const childProcess = require('child_process') 2 | const path = require('path') 3 | const util = require('util') 4 | const { catkeys } = require('./args') 5 | const getConfig = require('../../lib/getConfig') 6 | 7 | const execFile = util.promisify(childProcess.execFile) 8 | 9 | module.exports = { 10 | cmd: 'create-keys', 11 | desc: 12 | 'Create access keys to allow certcache clients to access certcache server', 13 | builder: { catkeys }, 14 | handler: async (argv) => { 15 | const execScript = path.resolve( 16 | __dirname, 17 | '..', 18 | '..', 19 | '..', 20 | 'node_modules', 21 | '.bin', 22 | 'catkeys' 23 | ) 24 | const { catKeysDir } = (await getConfig()) 25 | 26 | execFile( 27 | execScript, 28 | ['create-key', '--server', '--keydir', catKeysDir] 29 | ) 30 | .then(() => { 31 | execFile( 32 | execScript, 33 | ['create-key', '--keydir', catKeysDir, '--name', 'client'] 34 | ) 35 | }) 36 | .catch((err) => { 37 | console.error(err) 38 | }) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/cli/commands/get.js: -------------------------------------------------------------------------------- 1 | const getCert = require('../../lib/client/getCert') 2 | const { 3 | catkeys, 4 | days, 5 | ellipticCurve, 6 | keyType, 7 | upstream, 8 | httpRedirectUrl, 9 | maxRequestTime, 10 | skipFilePerms 11 | } = require('./args') 12 | 13 | module.exports = { 14 | cmd: 'get', 15 | desc: 'Get a single cert from Certcache server', 16 | builder: { 17 | catkeys, 18 | 'cert-name': { 19 | description: 'Certificate name (used for certificate directory name)' 20 | }, 21 | days, 22 | domains: { 23 | alias: 'd', 24 | description: 'List of comma-separated domain domains', 25 | required: true 26 | }, 27 | 'elliptic-curve': ellipticCurve, 28 | 'http-redirect-url': httpRedirectUrl, 29 | 'key-type': keyType, 30 | 'max-request-time': maxRequestTime, 31 | 'skip-file-perms': skipFilePerms, 32 | upstream 33 | }, 34 | handler: (argv) => { 35 | getCert(argv).catch((e) => { 36 | console.error(e.message) 37 | process.exit(1) 38 | }) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/cli/commands/index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | 'add-client': require('./add-client'), 3 | client: require('./client'), 4 | 'create-keys': require('./create-keys'), 5 | get: require('./get'), 6 | info: require('./info'), 7 | ls: require('./ls'), 8 | serve: require('./serve'), 9 | sync: require('./sync'), 10 | test: require('./testCmd') 11 | } 12 | -------------------------------------------------------------------------------- /src/cli/commands/info.js: -------------------------------------------------------------------------------- 1 | const outputInfo = require('../../lib/client/outputInfo') 2 | const { catkeys, upstream } = require('./args') 3 | 4 | module.exports = { 5 | cmd: 'info', 6 | desc: 'Display info about Certcache client and server', 7 | builder: { catkeys, upstream }, 8 | handler: outputInfo 9 | } 10 | -------------------------------------------------------------------------------- /src/cli/commands/ls.js: -------------------------------------------------------------------------------- 1 | const listCerts = require('../../lib/listCerts') 2 | 3 | module.exports = { 4 | cmd: 'ls', 5 | desc: 'List certificates', 6 | builder: { 7 | extensions: { 8 | description: 9 | 'List certificates generated by comma separated list of extensions', 10 | examples: 'certbot,thirdparty' 11 | } 12 | }, 13 | handler: (argv) => { 14 | listCerts(argv).catch((e) => { 15 | console.error(e) 16 | process.exit(1) 17 | }) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/cli/commands/serve.js: -------------------------------------------------------------------------------- 1 | const serve = require('../../lib/server/serve') 2 | const { catkeys } = require('./args') 3 | 4 | module.exports = { 5 | cmd: 'serve', 6 | desc: 'Start certcache server', 7 | builder: { 8 | catkeys, 9 | port: { 10 | alias: 'p', 11 | description: 'Port to run Certcache server' 12 | } 13 | }, 14 | handler: (argv) => { 15 | serve(argv) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/cli/commands/sync.js: -------------------------------------------------------------------------------- 1 | const syncPeriodically = require('../../lib/client/syncPeriodically') 2 | const { 3 | catkeys, 4 | days, 5 | ellipticCurve, 6 | httpRedirectUrl, 7 | keyType, 8 | maxRequestTime, 9 | skipFilePerms, 10 | upstream 11 | } = require('./args') 12 | 13 | module.exports = { 14 | cmd: 'sync', 15 | desc: 'Sync certificates with Certcache server', 16 | builder: { 17 | catkeys, 18 | days, 19 | 'elliptic-curve': ellipticCurve, 20 | forever: { 21 | description: 'Sync certificates continuously with Certcache server' 22 | }, 23 | 'http-redirect-url': httpRedirectUrl, 24 | interval: { 25 | description: 'Num minutes between polling for certificates' 26 | }, 27 | 'key-type': keyType, 28 | 'max-request-time': maxRequestTime, 29 | 'skip-file-perms': skipFilePerms, 30 | upstream 31 | }, 32 | handler: async (argv) => { 33 | await syncPeriodically(argv.forever) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/cli/commands/testCmd.js: -------------------------------------------------------------------------------- 1 | const test = require('../../lib/client/testCmd') 2 | const { catkeys, upstream } = require('./args') 3 | 4 | module.exports = { 5 | cmd: 'test', 6 | desc: 'Test the connection to the Certcache server', 7 | builder: { catkeys, upstream }, 8 | handler: test 9 | } 10 | -------------------------------------------------------------------------------- /src/config/config.js: -------------------------------------------------------------------------------- 1 | const defaults = require('./defaults') 2 | const yaml = require('yaml') 3 | 4 | module.exports = async ({ argv, env, file }) => { 5 | const _defaults = await defaults() 6 | 7 | return { 8 | binDir: env.CERTCACHE_BIN_DIR || 9 | file.binDir || 10 | _defaults.binDir, 11 | catKeysDir: argv.catkeys || 12 | env.CERTCACHE_CAH_KEYS_DIR || 13 | file.catKeysDir || 14 | _defaults.catKeysDir, 15 | certDir: env.CERTCACHE_CERTS_DIR || 16 | file.certDir || 17 | _defaults.certDir, 18 | certs: (env.CERTCACHE_CERTS && yaml.parse(env.CERTCACHE_CERTS)) || 19 | file.certs || 20 | _defaults.certs, 21 | ellipticCurve: argv['elliptic-curve'] || 22 | env.CERTCACHE_ELLIPTIC_CURVE || 23 | file.ellipticCurve || 24 | _defaults.ellipticCurve, 25 | httpRedirectUrl: argv['http-redirect-url'] || 26 | env.CERTCACHE_HTTP_REDIRECT_URL || 27 | file.httpRedirectUrl, 28 | httpRequestInterval: file.httpRequestInterval || 29 | _defaults.httpRequestInterval, 30 | keyType: argv['key-type'] || 31 | env.CERTCACHE_KEY_TYPE || 32 | file.keyType || 33 | _defaults.keyType, 34 | maxRequestTime: ( 35 | argv['max-request-time'] && 36 | Number(argv['max-request-time']) 37 | ) || 38 | ( 39 | env.CERTCACHE_MAX_REQUEST_TIME && 40 | Number(env.CERTCACHE_MAX_REQUEST_TIME) 41 | ) || 42 | file.maxRequestTime || 43 | _defaults.maxRequestTime, 44 | renewalDays: (argv.days && Number(argv.days)) || 45 | (env.CERTCACHE_DAYS_RENEWAL && Number(env.CERTCACHE_DAYS_RENEWAL)) || 46 | file.renewalDays || 47 | _defaults.renewalDays, 48 | server: { 49 | port: argv.port || 50 | env.CERTCACHE_PORT || 51 | file.server.port || 52 | _defaults.server.port, 53 | domainAccess: ( 54 | env.CERTCACHE_DOMAIN_ACCESS && 55 | yaml.parse(env.CERTCACHE_DOMAIN_ACCESS) 56 | ) || 57 | file.server.domainAccess 58 | }, 59 | skipFilePerms: argv['skip-file-perms'] || 60 | env.CERTCACHE_SKIP_FILE_PERMS === '1' || 61 | file.skipFilePerms || 62 | _defaults.skipFilePerms, 63 | syncInterval: argv.interval || 64 | env.CERTCACHE_SYNC_INTERVAL || 65 | file.syncInterval || 66 | _defaults.syncInterval, 67 | upstream: argv.upstream || 68 | env.CERTCACHE_UPSTREAM || 69 | file.upstream || 70 | _defaults.upstream 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/config/config.test.js: -------------------------------------------------------------------------------- 1 | /* global test expect */ 2 | 3 | const yaml = require('yaml') 4 | 5 | const config = require('./config') 6 | const file = { extensions: {}, server: {} } 7 | 8 | test( 9 | 'should parse yaml CERTCACHE_CERTS', 10 | async () => { 11 | const mockCerts = { test: 'item', foo: 123 } 12 | const env = { CERTCACHE_CERTS: yaml.stringify(mockCerts) } 13 | 14 | await expect(config({ argv: {}, env, file })) 15 | .resolves 16 | .toMatchObject({ certs: mockCerts }) 17 | } 18 | ) 19 | 20 | test( 21 | 'should parse yaml CERTCACHE_DOMAIN_ACCESS', 22 | async () => { 23 | const mockCertRestrictions = { test: 'item', bar: 432 } 24 | const env = { 25 | CERTCACHE_DOMAIN_ACCESS: yaml.stringify(mockCertRestrictions) 26 | } 27 | 28 | await expect(config({ argv: {}, env, file })) 29 | .resolves 30 | .toMatchObject({ server: { domainAccess: mockCertRestrictions } }) 31 | } 32 | ) 33 | 34 | test( 35 | 'should return renewalDays as a number', 36 | async () => { 37 | const renewalDays = 58008 38 | const env = { CERTCACHE_DAYS_RENEWAL: String(renewalDays) } 39 | const argv = { days: String(renewalDays) } 40 | 41 | await expect(config({ argv, env: {}, file })) 42 | .resolves 43 | .toMatchObject({ renewalDays }) 44 | await expect(config({ argv: {}, env, file })) 45 | .resolves 46 | .toMatchObject({ renewalDays }) 47 | } 48 | ) 49 | 50 | test( 51 | 'should return maxRequestTime as a number', 52 | async () => { 53 | const maxRequestTime = 58008 54 | const env = { CERTCACHE_MAX_REQUEST_TIME: String(maxRequestTime) } 55 | const argv = { 'max-request-time': String(maxRequestTime) } 56 | 57 | await expect(config({ argv, env: {}, file })) 58 | .resolves 59 | .toMatchObject({ maxRequestTime }) 60 | await expect(config({ argv: {}, env, file })) 61 | .resolves 62 | .toMatchObject({ maxRequestTime }) 63 | } 64 | ) 65 | -------------------------------------------------------------------------------- /src/config/defaults.js: -------------------------------------------------------------------------------- 1 | const fileExists = require('../lib/helpers/fileExists') 2 | 3 | module.exports = async () => ({ 4 | binDir: 'bin', 5 | catKeysDir: await fileExists('cahkeys') ? 'cahkeys' : 'catkeys', 6 | certDir: 'certs', 7 | certs: [], 8 | ellipticCurve: 'secp256r1', 9 | httpRequestInterval: 1, 10 | keyType: 'rsa', 11 | maxRequestTime: 90, 12 | renewalDays: 30, 13 | server: { port: 4433 }, 14 | syncInterval: 60 * 6, 15 | upstream: '--internal' 16 | }) 17 | -------------------------------------------------------------------------------- /src/config/defaults.test.js: -------------------------------------------------------------------------------- 1 | /* global jest beforeEach test expect */ 2 | 3 | const fileExists = require('../lib/helpers/fileExists') 4 | 5 | jest.mock('../lib/helpers/fileExists') 6 | 7 | const defaults = require('./defaults') 8 | 9 | const cahkeysExists = async (dir) => (dir === 'cahkeys') 10 | const catkeysExists = async (dir) => (dir === 'catkeys') 11 | 12 | beforeEach(() => { 13 | fileExists.mockReset() 14 | }) 15 | 16 | test( 17 | 'should find catkeys directory when catkeys exists and cahkeys does not', 18 | async () => { 19 | fileExists.mockImplementation(catkeysExists) 20 | 21 | await expect(defaults()).resolves.toMatchObject({ catKeysDir: 'catkeys' }) 22 | } 23 | ) 24 | test( 25 | 'should find cahkeys directory when cahkeys exists and catkeys does not', 26 | async () => { 27 | fileExists.mockImplementation(cahkeysExists) 28 | 29 | await expect(defaults()).resolves.toMatchObject({ catKeysDir: 'cahkeys' }) 30 | } 31 | ) 32 | -------------------------------------------------------------------------------- /src/extensions/certbot/canGenerateDomains.js: -------------------------------------------------------------------------------- 1 | const getConfig = require('../../lib/getConfig') 2 | const allItemsPresent = require('../../lib/helpers/allItemsPresent') 3 | const canonicaliseDomains = require('./lib/canonicaliseDomains') 4 | 5 | module.exports = async (domains) => { 6 | const config = (await getConfig()).extensions.certbot 7 | 8 | return allItemsPresent( 9 | domains, 10 | canonicaliseDomains(config.domains).map(({ domain }) => domain) 11 | ) 12 | } 13 | -------------------------------------------------------------------------------- /src/extensions/certbot/canGenerateDomains.test.js: -------------------------------------------------------------------------------- 1 | /* global jest test expect */ 2 | 3 | const canGenerateDomains = require('./canGenerateDomains') 4 | 5 | jest.mock('../../lib/getConfig') 6 | 7 | const mockDomains = ['test.93million.com', 'foo.example.com'] 8 | 9 | test( 10 | 'should return true if every domain matches a domain listed in config', 11 | async () => { 12 | await expect(canGenerateDomains(mockDomains)).resolves.toBe(true) 13 | } 14 | ) 15 | 16 | test( 17 | 'should return false if every domain matches a domain listed in config', 18 | async () => { 19 | await expect(canGenerateDomains([ 20 | ...mockDomains, 21 | 'foo.93million.com' 22 | ])).resolves.toBe(false) 23 | } 24 | ) 25 | -------------------------------------------------------------------------------- /src/extensions/certbot/commandArgs.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | get: { 3 | 'test-cert': { 4 | alias: 't', 5 | boolean: true, 6 | description: 'Generate a test certificate when using Certbot' 7 | } 8 | }, 9 | serve: { 10 | 'certbot-default-challenge': { 11 | description: 12 | 'Default challenge to use when obtaining certificates using Certbot' 13 | }, 14 | 'certbot-email': { 15 | description: 'Email to use when obtaining certificates using Certbot' 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/extensions/certbot/config.js: -------------------------------------------------------------------------------- 1 | const yaml = require('yaml') 2 | 3 | const defaults = { 4 | certbotConfigDir: 'cache/certbot/config', 5 | certbotExec: 'certbot', 6 | certbotLogsDir: 'cache/certbot/logs', 7 | certbotWorkDir: 'cache/certbot/work', 8 | challenges: {}, 9 | defaultChallenge: 'dns-01', 10 | domains: ['~.'], 11 | 'test-cert': false 12 | } 13 | 14 | module.exports = ({ argv, env, file }) => { 15 | return { 16 | certbotConfigDir: env.CERTCACHE_CERTBOT_CONFIG_DIR || 17 | file.certbotConfigDir || 18 | defaults.certbotConfigDir, 19 | certbotExec: env.CERTCACHE_CERTBOT_EXEC || 20 | file.certbotExec || 21 | defaults.certbotExec, 22 | certbotLogsDir: file.certbotLogsDir || 23 | defaults.certbotLogsDir, 24 | certbotWorkDir: file.certbotWorkDir || 25 | defaults.certbotWorkDir, 26 | challenges: ( 27 | env.CERTCACHE_CERTBOT_CHALLENGES && 28 | yaml.parse(env.CERTCACHE_CERTBOT_CHALLENGES) 29 | ) || 30 | file.challenges || 31 | defaults.challenges, 32 | defaultChallenge: argv['certbot-default-challenge'] || 33 | env.CERTCACHE_CERTBOT_DEFAULT_CHALLENGE || 34 | file.defaultChallenge || 35 | defaults.defaultChallenge, 36 | domains: ( 37 | env.CERTCACHE_CERTBOT_DOMAINS && 38 | yaml.parse(env.CERTCACHE_CERTBOT_DOMAINS) 39 | ) || 40 | file.domains || 41 | defaults.domains, 42 | email: argv['certbot-email'] || 43 | env.CERTCACHE_CERTBOT_EMAIL || 44 | file.email, 45 | 'test-cert': argv['test-cert'] || 46 | env.CERTCACHE_TEST_CERT === '1' || 47 | file['test-cert'] || 48 | defaults['test-cert'] 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/extensions/certbot/config.test.js: -------------------------------------------------------------------------------- 1 | /* global test expect */ 2 | 3 | const yaml = require('yaml') 4 | 5 | const config = require('./config') 6 | const file = { extensions: {}, server: {} } 7 | 8 | test( 9 | 'should parse yaml CERTCACHE_CERTBOT_DOMAINS', 10 | () => { 11 | const mockDomains = { test: 'domain', bar: 432 } 12 | const env = { CERTCACHE_CERTBOT_DOMAINS: yaml.stringify(mockDomains) } 13 | 14 | expect(config({ argv: {}, env, file }).domains).toEqual(mockDomains) 15 | } 16 | ) 17 | 18 | test( 19 | 'should parse yaml CERTCACHE_CERTBOT_CHALLENGES', 20 | () => { 21 | const mockChallenges = { mockChallenge: { args: '--foo' } } 22 | const env = { 23 | CERTCACHE_CERTBOT_CHALLENGES: yaml.stringify(mockChallenges) 24 | } 25 | 26 | expect(config({ argv: {}, env, file }).challenges) 27 | .toEqual(mockChallenges) 28 | } 29 | ) 30 | -------------------------------------------------------------------------------- /src/extensions/certbot/generateCert.js: -------------------------------------------------------------------------------- 1 | const generateCertName = require('./lib/generateCertName') 2 | const getCertbotCertonlyArgs = require('./lib/getCertbotCertonlyArgs') 3 | const debug = require('debug')('certcache:generateCert') 4 | const execCertbot = require('./lib/execCertbot') 5 | const getConfig = require('../../lib/getConfig') 6 | const getChallengeFromDomains = require('./lib/getChallengeFromDomains') 7 | const FeedbackError = require('../../lib/FeedbackError') 8 | 9 | const certsInGeneration = {} 10 | 11 | module.exports = async (commonName, altNames, meta) => { 12 | const certName = generateCertName(commonName, altNames, meta) 13 | let certbotConfig 14 | 15 | if (certsInGeneration[certName] === undefined) { 16 | certsInGeneration[certName] = (async () => { 17 | const domains = Array.from(new Set([commonName, ...altNames])) 18 | const certbotConfig = (await getConfig()).extensions.certbot 19 | const challenge = await getChallengeFromDomains( 20 | certbotConfig.domains, 21 | domains, 22 | certbotConfig.defaultChallenge 23 | ) 24 | 25 | if (challenge === undefined) { 26 | throw new FeedbackError([ 27 | 'Unable to find a common certbot challenge to generate the requested', 28 | 'combination of domains:', 29 | domains.join(', ') 30 | ].join(' ')) 31 | } 32 | 33 | const certbotArgs = getCertbotCertonlyArgs( 34 | commonName, 35 | altNames, 36 | certName, 37 | meta, 38 | certbotConfig, 39 | challenge.args 40 | ) 41 | debug( 42 | 'Generating certificate by calling', 43 | certbotConfig.certbotExec, 44 | certbotArgs.join(' ') 45 | ) 46 | 47 | await execCertbot( 48 | certbotConfig.certbotExec, 49 | certbotArgs, 50 | { env: { ...process.env, ...challenge.environment } } 51 | ) 52 | 53 | return certbotConfig 54 | })() 55 | } 56 | 57 | try { 58 | certbotConfig = await certsInGeneration[certName] 59 | debug('Generated cert successfully using certbot') 60 | } catch (e) { 61 | debug('Cert generation failed using certbot', e) 62 | throw e 63 | } finally { 64 | delete certsInGeneration[certName] 65 | } 66 | 67 | return `${certbotConfig.certbotConfigDir}/live/${certName}/cert.pem` 68 | } 69 | -------------------------------------------------------------------------------- /src/extensions/certbot/generateCert.test.js: -------------------------------------------------------------------------------- 1 | /* global jest test expect beforeEach */ 2 | 3 | const childProcess = require('child_process') 4 | const generateCert = require('./generateCert') 5 | const generateCertName = require('./lib/generateCertName') 6 | const getConfig = require('../../lib/getConfig') 7 | const getChallengeFromDomains = require('./lib/getChallengeFromDomains') 8 | const FeedbackError = require('../../lib/FeedbackError') 9 | 10 | jest.mock('child_process') 11 | jest.mock('../../lib/getConfig') 12 | jest.mock('./lib/getChallengeFromDomains') 13 | 14 | const commonName = 'test.example.com' 15 | const altNames = ['test.example.com', 'test1.example.com', 'test.93million.com'] 16 | const mockChallenge = { 17 | args: ['--test-arg1', '--test-arg2'], 18 | environment: { item: '123' } 19 | } 20 | const meta = { isTest: true } 21 | let certbotConfig 22 | 23 | getChallengeFromDomains.mockReturnValue(Promise.resolve(mockChallenge)) 24 | 25 | beforeEach(async () => { 26 | childProcess.execFile.mockReset() 27 | childProcess.execFile.mockImplementation((exec, args, options, callback) => { 28 | callback(null, true) 29 | }) 30 | certbotConfig = (await getConfig()).extensions.certbot 31 | }) 32 | 33 | test( 34 | 'should not create duplicate requests for the same certificate', 35 | async () => { 36 | await Promise.all([ 37 | generateCert(commonName, altNames, meta), 38 | generateCert(commonName, altNames, meta) 39 | ]) 40 | 41 | expect(childProcess.execFile).toBeCalledTimes(1) 42 | } 43 | ) 44 | 45 | test( 46 | 'should return path to newly generated certificate', 47 | async () => { 48 | const certPath = await generateCert(commonName, altNames, meta) 49 | const certName = generateCertName(commonName, altNames, meta) 50 | 51 | expect(certPath) 52 | .toBe(`${certbotConfig.certbotConfigDir}/live/${certName}/cert.pem`) 53 | } 54 | ) 55 | 56 | test( 57 | 'should throw errors encountered', 58 | async () => { 59 | childProcess.execFile.mockImplementationOnce(( 60 | exec, 61 | args, 62 | options, 63 | callback 64 | ) => { 65 | callback(new Error('certbot exited with error'), null) 66 | }) 67 | 68 | await expect(generateCert(commonName, altNames, meta)) 69 | .rejects 70 | .toThrow('certbot exited with error') 71 | } 72 | ) 73 | 74 | test( 75 | 'should throw feedback error if common challenge cannot be found for domains', 76 | async () => { 77 | getChallengeFromDomains.mockReturnValueOnce(Promise.resolve(undefined)) 78 | 79 | await expect(generateCert(commonName, altNames, meta)) 80 | .rejects 81 | .toThrow(FeedbackError) 82 | } 83 | ) 84 | 85 | test( 86 | 'should pass challenge args to certbot', 87 | async () => { 88 | await generateCert(commonName, altNames, meta) 89 | 90 | expect(childProcess.execFile).toBeCalledWith( 91 | certbotConfig.certbotExec, 92 | expect.arrayContaining(mockChallenge.args), 93 | expect.any(Object), 94 | expect.any(Function) 95 | ) 96 | } 97 | ) 98 | 99 | test( 100 | 'should pass challenge env to certbot', 101 | async () => { 102 | await generateCert(commonName, altNames, meta) 103 | 104 | expect(childProcess.execFile).toBeCalledWith( 105 | certbotConfig.certbotExec, 106 | expect.any(Array), 107 | { env: expect.objectContaining(mockChallenge.environment) }, 108 | expect.any(Function) 109 | ) 110 | } 111 | ) 112 | -------------------------------------------------------------------------------- /src/extensions/certbot/getBundle.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const fs = require('fs') 3 | const util = require('util') 4 | 5 | const readFile = util.promisify(fs.readFile) 6 | 7 | module.exports = async (cert) => { 8 | const dirname = path.dirname(cert.certPath) 9 | 10 | return { 11 | cert: await readFile(`${dirname}/cert.pem`), 12 | chain: await readFile(`${dirname}/chain.pem`), 13 | privkey: await readFile(`${dirname}/privkey.pem`) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/extensions/certbot/getBundle.test.js: -------------------------------------------------------------------------------- 1 | /* global jest test expect */ 2 | 3 | const getBundle = require('./getBundle') 4 | const fs = require('fs') 5 | 6 | jest.mock('fs') 7 | 8 | const mockCertContents = '_test_cert_' 9 | const mockChainContents = '_test_chain_' 10 | const mockPrivkeyContents = '_test_privkey_' 11 | const mockCertObj = { certPath: '/test/path/to/cert.pem' } 12 | 13 | fs.readFile.mockImplementation((path, callback) => { 14 | const fileContentsMap = { 15 | [`/test/path/to/cert.pem`]: mockCertContents, 16 | [`/test/path/to/chain.pem`]: mockChainContents, 17 | [`/test/path/to/privkey.pem`]: mockPrivkeyContents 18 | } 19 | 20 | callback(null, Promise.resolve(fileContentsMap[path])) 21 | }) 22 | 23 | test( 24 | 'should return an object based on file path', 25 | async () => { 26 | expect(await getBundle(mockCertObj)).toEqual({ 27 | cert: mockCertContents, 28 | chain: mockChainContents, 29 | privkey: mockPrivkeyContents 30 | }) 31 | } 32 | ) 33 | -------------------------------------------------------------------------------- /src/extensions/certbot/getLocalCerts.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | const util = require('util') 3 | const readdir = util.promisify(fs.readdir) 4 | const fileExists = require('../../lib/helpers/fileExists') 5 | const path = require('path') 6 | const getConfig = require('../../lib/getConfig') 7 | const Certificate = require('../../lib/classes/Certificate') 8 | let handlers 9 | 10 | const getLocalCerts = async () => { 11 | const config = (await getConfig()).extensions.certbot 12 | const certDir = path.resolve(config.certbotConfigDir, 'live') 13 | const dirItems = await readdir(certDir).catch(() => []) 14 | const certPaths = dirItems.map((item) => `${certDir}/${item}/cert.pem`) 15 | const existsArr = await Promise.all(certPaths.map(fileExists)) 16 | 17 | if (handlers === undefined) { 18 | handlers = require('.') 19 | } 20 | 21 | return Promise.all(certPaths 22 | .filter((certPath, i) => existsArr[i]) 23 | .map(async (certPath) => Certificate.fromPath(handlers, certPath)) 24 | ) 25 | } 26 | 27 | module.exports = getLocalCerts 28 | -------------------------------------------------------------------------------- /src/extensions/certbot/getLocalCerts.test.js: -------------------------------------------------------------------------------- 1 | /* global jest test expect */ 2 | 3 | const getLocalCerts = require('./getLocalCerts') 4 | const fs = require('fs') 5 | const fileExists = require('../../lib/helpers/fileExists') 6 | const path = require('path') 7 | const Certificate = require('../../lib/classes/Certificate') 8 | 9 | jest.mock('fs') 10 | jest.mock('../../lib/helpers/fileExists') 11 | jest.mock('path') 12 | jest.mock('../../lib/classes/Certificate') 13 | jest.mock('../../lib/getConfig.js') 14 | 15 | const dirContents = ['cert1', 'cert2', 'cert3'] 16 | const filePaths = [ 17 | '/', 18 | '/test', 19 | '/test/certs', 20 | '/test/certs/cert1', 21 | '/test/certs/cert1/cert.pem', 22 | '/test/certs/cert2', 23 | '/test/certs/cert2/cert.pem', 24 | '/test/certs/cert3/', 25 | '/test/certs/cert3/cert.pem', 26 | '/test/certs/notcert1', 27 | '/test/certs/notcert1/file1', 28 | '/test/certs/notcert1/file2', 29 | '/test/certs/notcert2/file1', 30 | '/test/certs/notcert2/file2', 31 | '/test/certs/notcert2/file3' 32 | ] 33 | 34 | path.resolve.mockReturnValue('/test/certs') 35 | Certificate.fromPath 36 | .mockImplementation((handlers, path) => { 37 | return Promise.resolve({ handlers, path }) 38 | }) 39 | 40 | fileExists.mockImplementation((path) => filePaths.includes(path)) 41 | const expectedHandler = { 42 | canGenerateDomains: expect.any(Function), 43 | commandArgs: expect.any(Object), 44 | config: expect.any(Function), 45 | generateCert: expect.any(Function), 46 | getBundle: expect.any(Function), 47 | getLocalCerts: expect.any(Function), 48 | getMetaFromCert: expect.any(Function), 49 | getMetaFromCertDefinition: expect.any(Function), 50 | getMetaFromConfig: expect.any(Function), 51 | normalizeMeta: expect.any(Function) 52 | } 53 | 54 | test( 55 | 'should return an array of certificates', 56 | async () => { 57 | fs.readdir 58 | .mockImplementation((path, callback) => callback(null, dirContents)) 59 | 60 | const received = await getLocalCerts() 61 | 62 | expect(received).toEqual([ 63 | { handlers: expectedHandler, path: '/test/certs/cert1/cert.pem' }, 64 | { handlers: expectedHandler, path: '/test/certs/cert2/cert.pem' }, 65 | { handlers: expectedHandler, path: '/test/certs/cert3/cert.pem' } 66 | ]) 67 | } 68 | ) 69 | 70 | test( 71 | 'should return an ampty array when directory doesn\'t exist', 72 | async () => { 73 | const error = { 74 | ...new Error(`ENOENT: no such file or directory, stat '${path}'`), 75 | code: 'ENOENT', 76 | path, 77 | syscall: 'stat' 78 | } 79 | 80 | fs.readdir 81 | .mockImplementation((path, callback) => callback(error, null)) 82 | 83 | await expect(getLocalCerts()).resolves.toEqual([]) 84 | } 85 | ) 86 | -------------------------------------------------------------------------------- /src/extensions/certbot/getMetaFromCert.js: -------------------------------------------------------------------------------- 1 | module.exports = ({ asn1Curve, issuerCommonName }) => { 2 | /* curveMaps sourced from https://tools.ietf.org/search/rfc4492#page-32 3 | see 'Appendix A. Equivalent Curves (Informative)' */ 4 | const curveMaps = { 5 | prime192v1: 'secp192r1', 6 | prime256v1: 'secp256r1' 7 | } 8 | const curve = (curveMaps[asn1Curve] !== undefined) 9 | ? curveMaps[asn1Curve] 10 | : asn1Curve 11 | 12 | return { 13 | ellipticCurve: curve, 14 | isTest: ( 15 | issuerCommonName.startsWith('Fake LE ') || 16 | issuerCommonName.startsWith('(STAGING)') 17 | ), 18 | keyType: (asn1Curve !== undefined) ? 'ecdsa' : 'rsa' 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/extensions/certbot/getMetaFromCert.test.js: -------------------------------------------------------------------------------- 1 | /* global test expect */ 2 | 3 | const getMetaFromCert = require('./getMetaFromCert') 4 | 5 | const mockTestCert = { issuerCommonName: 'Fake LE Intermediate X1' } 6 | const mockRealCert = { issuerCommonName: 'Let\'s Encrypt Authority X3' } 7 | const mockTestMeta = { isTest: true, keyType: 'rsa' } 8 | const mockRealMeta = { isTest: false, keyType: 'rsa' } 9 | 10 | test( 11 | 'should identify test certificate from certificate\'s isserCommonName', 12 | () => { 13 | expect(getMetaFromCert(mockTestCert)).toEqual(mockTestMeta) 14 | expect(getMetaFromCert(mockRealCert)).toEqual(mockRealMeta) 15 | } 16 | ) 17 | 18 | test( 19 | 'should identify ecdsa certs from certificate\'s asn1Curve', 20 | () => { 21 | expect(getMetaFromCert({ 22 | ...mockRealCert, 23 | asn1Curve: 'secp384r1' 24 | })) 25 | .toMatchObject({ 26 | ellipticCurve: 'secp384r1', 27 | keyType: 'ecdsa' 28 | }) 29 | } 30 | ) 31 | 32 | test( 33 | 'should map ec curves', 34 | () => { 35 | expect(getMetaFromCert({ 36 | ...mockRealCert, 37 | asn1Curve: 'prime256v1' 38 | })) 39 | .toMatchObject({ 40 | ellipticCurve: 'secp256r1', 41 | keyType: 'ecdsa' 42 | }) 43 | } 44 | ) 45 | -------------------------------------------------------------------------------- /src/extensions/certbot/getMetaFromCertDefinition.js: -------------------------------------------------------------------------------- 1 | const getConfig = require('../../lib/getConfig') 2 | 3 | module.exports = async ({ ellipticCurve, keyType, testCert }) => { 4 | const config = await getConfig() 5 | 6 | ellipticCurve = ellipticCurve || config.ellipticCurve 7 | keyType = keyType || config.keyType 8 | 9 | keyType = keyType.toLocaleLowerCase() 10 | ellipticCurve = ellipticCurve.toLocaleLowerCase() 11 | 12 | return { 13 | ellipticCurve: (keyType === 'ecdsa') ? ellipticCurve : undefined, 14 | isTest: (testCert === true), 15 | keyType 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/extensions/certbot/getMetaFromCertDefinition.test.js: -------------------------------------------------------------------------------- 1 | /* global test expect */ 2 | 3 | const getMetaFromCertDefinition = require('./getMetaFromCertDefinition') 4 | 5 | const ellipticCurve = 'testcurve' 6 | const keyType = 'ecdsa' 7 | const testCert = true 8 | 9 | test( 10 | 'should return object when supplied with certDefinition', 11 | async () => { 12 | await expect(getMetaFromCertDefinition({ 13 | ellipticCurve, 14 | keyType, 15 | testCert 16 | })) 17 | .resolves 18 | .toEqual({ ellipticCurve, keyType, isTest: testCert }) 19 | } 20 | ) 21 | 22 | test( 23 | // eslint-disable-next-line max-len 24 | 'should return property ellipticCurve of undefined unless keyType = \'ecdsa\'', 25 | () => { 26 | expect(getMetaFromCertDefinition({ 27 | ellipticCurve, 28 | keyType: 'rsa', 29 | testCert 30 | })) 31 | .toHaveProperty('ellipticCurve', undefined) 32 | } 33 | ) 34 | 35 | test( 36 | 'should return default prop isTest of false', 37 | async () => { 38 | await expect(getMetaFromCertDefinition({})) 39 | .resolves 40 | .toHaveProperty('isTest', false) 41 | } 42 | ) 43 | -------------------------------------------------------------------------------- /src/extensions/certbot/getMetaFromConfig.js: -------------------------------------------------------------------------------- 1 | const getConfig = require('../../lib/getConfig') 2 | 3 | module.exports = async ({ ellipticCurve, keyType, 'test-cert': testCert }) => { 4 | const config = await getConfig() 5 | 6 | ellipticCurve = ellipticCurve || config.ellipticCurve 7 | keyType = keyType || config.keyType 8 | 9 | keyType = keyType.toLocaleLowerCase() 10 | ellipticCurve = ellipticCurve.toLocaleLowerCase() 11 | 12 | return { 13 | ellipticCurve: (keyType === 'ecdsa') ? ellipticCurve : undefined, 14 | isTest: testCert === true, 15 | keyType: keyType 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/extensions/certbot/getMetaFromConfig.test.js: -------------------------------------------------------------------------------- 1 | /* global test expect */ 2 | 3 | const getMetaFromConfig = require('./getMetaFromConfig') 4 | 5 | const ellipticCurve = 'testcurve' 6 | const keyType = 'ecdsa' 7 | const testCert = true 8 | 9 | test( 10 | 'should return object when supplied with certDefinition', 11 | async () => { 12 | await expect(getMetaFromConfig({ 13 | ellipticCurve, 14 | keyType, 15 | 'test-cert': testCert 16 | })) 17 | .resolves 18 | .toEqual({ ellipticCurve, keyType, isTest: testCert }) 19 | } 20 | ) 21 | 22 | test( 23 | // eslint-disable-next-line max-len 24 | 'should return property ellipticCurve of undefined unless keyType = \'ecdsa\'', 25 | () => { 26 | expect(getMetaFromConfig({ 27 | ellipticCurve, 28 | keyType: 'rsa', 29 | 'test-cert': testCert 30 | })) 31 | .toHaveProperty('ellipticCurve', undefined) 32 | } 33 | ) 34 | 35 | test( 36 | 'should return default prop isTest of false', 37 | async () => { 38 | await expect(getMetaFromConfig({})).resolves.toHaveProperty('isTest', false) 39 | } 40 | ) 41 | -------------------------------------------------------------------------------- /src/extensions/certbot/index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | canGenerateDomains: require('./canGenerateDomains'), 3 | commandArgs: require('./commandArgs'), 4 | config: require('./config'), 5 | generateCert: require('./generateCert'), 6 | getBundle: require('./getBundle'), 7 | getLocalCerts: require('./getLocalCerts'), 8 | getMetaFromCert: require('./getMetaFromCert'), 9 | getMetaFromCertDefinition: require('./getMetaFromCertDefinition'), 10 | getMetaFromConfig: require('./getMetaFromConfig'), 11 | normalizeMeta: require('./normalizeMeta') 12 | } 13 | -------------------------------------------------------------------------------- /src/extensions/certbot/lib/canonicaliseDomains.js: -------------------------------------------------------------------------------- 1 | module.exports = (domains, { defaultChallenge } = {}) => { 2 | if (typeof domains === 'string') { 3 | domains = domains.split(',') 4 | } 5 | 6 | return domains.map((domain) => { 7 | if (typeof domain === 'string') { 8 | domain = { domain } 9 | } 10 | 11 | domain = { ...domain } 12 | 13 | if (domain.challenges === undefined && defaultChallenge !== undefined) { 14 | domain.challenges = [defaultChallenge] 15 | } 16 | 17 | return domain 18 | }) 19 | } 20 | -------------------------------------------------------------------------------- /src/extensions/certbot/lib/canonicaliseDomains.test.js: -------------------------------------------------------------------------------- 1 | /* global test expect */ 2 | 3 | const canonicaliseDomains = require('./canonicaliseDomains') 4 | 5 | const mockDomains = [ 6 | { domain: 'test1.example.com' }, 7 | { domain: 'test2.example.com' } 8 | ] 9 | 10 | test( 11 | 'should accept domains as comma separated strings', 12 | () => { 13 | expect(canonicaliseDomains('test1.example.com,test2.example.com')) 14 | .toEqual(mockDomains) 15 | } 16 | ) 17 | 18 | test( 19 | 'should accept domains as array of strings', 20 | () => { 21 | expect(canonicaliseDomains(['test1.example.com', 'test2.example.com'])) 22 | .toEqual(mockDomains) 23 | } 24 | ) 25 | 26 | test( 27 | 'should accept domains as array of objects', 28 | () => { 29 | expect(canonicaliseDomains(mockDomains)) 30 | .toEqual(mockDomains) 31 | } 32 | ) 33 | 34 | test( 35 | 'should add default challenge', 36 | () => { 37 | expect(canonicaliseDomains(mockDomains, { defaultChallenge: 'default-01' })) 38 | .toEqual(expect.arrayContaining([expect.objectContaining({ 39 | challenges: ['default-01'] 40 | })])) 41 | } 42 | ) 43 | 44 | test( 45 | 'should not mutate domains object passed in args', 46 | () => { 47 | canonicaliseDomains(mockDomains, { defaultChallenge: 'default-01' }) 48 | expect(mockDomains) 49 | .not 50 | .toEqual(expect.arrayContaining([expect.objectContaining({ 51 | challenges: ['default-01'] 52 | })])) 53 | } 54 | ) 55 | -------------------------------------------------------------------------------- /src/extensions/certbot/lib/challenges/dns01.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | args: [ 3 | '--preferred-challenges', 4 | 'dns', 5 | '--authenticator', 6 | 'certbot-dns-standalone:dns-standalone' 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /src/extensions/certbot/lib/challenges/http01.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | args: [ 3 | '--standalone' 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /src/extensions/certbot/lib/challenges/index.js: -------------------------------------------------------------------------------- 1 | const dns01 = require('./dns01') 2 | const http01 = require('./http01') 3 | 4 | module.exports = { 'dns-01': dns01, 'http-01': http01 } 5 | -------------------------------------------------------------------------------- /src/extensions/certbot/lib/execCertbot.js: -------------------------------------------------------------------------------- 1 | const childProcess = require('child_process') 2 | const util = require('util') 3 | const concurrencyLimiter = require('../../../lib/helpers/concurrencyLimiter') 4 | 5 | const execFile = util.promisify(childProcess.execFile) 6 | 7 | module.exports = concurrencyLimiter(execFile, 1) 8 | -------------------------------------------------------------------------------- /src/extensions/certbot/lib/generateCertName.js: -------------------------------------------------------------------------------- 1 | const md5 = require('md5') 2 | 3 | module.exports = ( 4 | commonName, 5 | altNames, 6 | { isTest = false, keyType, ellipticCurve } = {} 7 | ) => md5(JSON.stringify({ 8 | commonName, 9 | altNames: altNames.map((name) => name.toLowerCase()).sort(), 10 | isTest, 11 | keyType, 12 | ellipticCurve 13 | })) 14 | -------------------------------------------------------------------------------- /src/extensions/certbot/lib/generateCertName.test.js: -------------------------------------------------------------------------------- 1 | /* global test expect */ 2 | 3 | const generateCertName = require('./generateCertName') 4 | const md5 = require('md5') 5 | 6 | const commonName = 'example.com' 7 | const altNames = ['www.example.com', 'www1.example.com'] 8 | const isTest = true 9 | 10 | test( 11 | 'should generate a certificate from an MD5 hash of the certificate values', 12 | () => { 13 | expect(generateCertName(commonName, altNames, { isTest })) 14 | .toBe(md5(JSON.stringify({ commonName, altNames, isTest }))) 15 | } 16 | ) 17 | 18 | test( 19 | 'should default to genuine (non-test) certificates unless specified', 20 | () => { 21 | expect(generateCertName(commonName, altNames)) 22 | .toBe(md5(JSON.stringify({ commonName, altNames, isTest: false }))) 23 | } 24 | ) 25 | -------------------------------------------------------------------------------- /src/extensions/certbot/lib/getCertbotCertonlyArgs.js: -------------------------------------------------------------------------------- 1 | module.exports = ( 2 | commonName, 3 | altNames, 4 | certName, 5 | { isTest, keyType, ellipticCurve }, 6 | { certbotConfigDir, certbotLogsDir, certbotWorkDir, email }, 7 | extraArgs 8 | ) => { 9 | if (email === undefined) { 10 | throw new Error([ 11 | 'Missing email address to obtain letsencrypt certificates.', 12 | 'Please provide env CERTCACHE_CERTBOT_EMAIL, pass in using cli', 13 | 'arg --certbot-email or specify in settings.json at', 14 | 'extensions.certbot.email' 15 | ].join(' ')) 16 | } 17 | 18 | const domains = Array.from(new Set([commonName, ...altNames])) 19 | const certbotArgs = [ 20 | 'certonly', 21 | '--non-interactive', 22 | '--break-my-certs', 23 | '--agree-tos', 24 | '--no-eff-email', 25 | `-d`, 26 | domains.join(','), 27 | `--cert-name`, 28 | certName, 29 | `-m`, 30 | email, 31 | `--config-dir`, 32 | certbotConfigDir, 33 | `--logs-dir`, 34 | certbotLogsDir, 35 | `--work-dir`, 36 | certbotWorkDir, 37 | '--force-renewal', 38 | ...extraArgs 39 | ] 40 | 41 | if (isTest) { 42 | certbotArgs.push('--test-cert') 43 | } 44 | 45 | if (keyType !== undefined) { 46 | certbotArgs.push('--key-type') 47 | certbotArgs.push(keyType) 48 | } 49 | 50 | if (ellipticCurve !== undefined) { 51 | certbotArgs.push('--elliptic-curve') 52 | certbotArgs.push(ellipticCurve) 53 | } 54 | 55 | return certbotArgs 56 | } 57 | -------------------------------------------------------------------------------- /src/extensions/certbot/lib/getChallengeFromDomains.js: -------------------------------------------------------------------------------- 1 | const challenges = require('./challenges') 2 | const canonicaliseDomains = require('./canonicaliseDomains') 3 | const allItemsPresent = require('../../../lib/helpers/allItemsPresent') 4 | const getConfig = require('../../../lib/getConfig') 5 | 6 | const groupCertbotDomainsByChallengeType = (certbotDomains, challengeTypes) => { 7 | return challengeTypes.reduce( 8 | (acc, challengeType) => { 9 | return { 10 | ...acc, 11 | [challengeType]: certbotDomains.reduce( 12 | (acc, { domain, challenges }) => { 13 | if (challenges.includes(challengeType) === true) { 14 | acc.push(domain) 15 | } 16 | return acc 17 | }, 18 | [] 19 | ) 20 | } 21 | }, 22 | {} 23 | ) 24 | } 25 | 26 | module.exports = async (certbotDomains, domains, defaultChallenge) => { 27 | const config = await getConfig() 28 | const _challenges = { ...config.extensions.certbot.challenges, ...challenges } 29 | const challengeTypes = Object.keys(_challenges) 30 | const certbotDomainsByChallengeTypes = groupCertbotDomainsByChallengeType( 31 | canonicaliseDomains(certbotDomains, { defaultChallenge }), 32 | challengeTypes 33 | ) 34 | const challengeType = challengeTypes.find((challengeType) => { 35 | return allItemsPresent( 36 | domains, 37 | certbotDomainsByChallengeTypes[challengeType] 38 | ) 39 | }) 40 | 41 | return challengeType && _challenges[challengeType] 42 | } 43 | -------------------------------------------------------------------------------- /src/extensions/certbot/lib/getChallengeFromDomains.test.js: -------------------------------------------------------------------------------- 1 | /* global test expect */ 2 | 3 | const getChallengeFromDomains = require('./getChallengeFromDomains') 4 | const challenges = require('./challenges') 5 | 6 | challenges.mockChallenge1 = 1 7 | challenges.mockChallenge2 = 2 8 | challenges.mockChallenge3 = 3 9 | 10 | const mockCertbotDomains = [ 11 | { 12 | domain: '~test\\d.example.com', 13 | challenges: ['mockChallenge1', 'mockChallenge3'] 14 | }, 15 | { 16 | domain: 'example.com', 17 | challenges: ['mockChallenge2', 'mockChallenge3'] 18 | }, 19 | { domain: 'foo.example.com' } 20 | ] 21 | 22 | const mockDomains = [ 23 | 'example.com', 24 | 'test1.example.com', 25 | 'test2.example.com' 26 | ] 27 | 28 | test( 29 | 'should return challenge shared between domains', 30 | async () => { 31 | await expect(getChallengeFromDomains( 32 | mockCertbotDomains, 33 | mockDomains, 34 | 'mockChallenge2' 35 | )) 36 | .resolves 37 | .toBe(challenges.mockChallenge3) 38 | } 39 | ) 40 | 41 | test( 42 | 'should use default chalenge when none provided', 43 | async () => { 44 | const challenge = await getChallengeFromDomains( 45 | mockCertbotDomains, 46 | ['foo.example.com', 'test7.example.com'], 47 | 'mockChallenge1' 48 | ) 49 | 50 | expect(challenge).toBe(challenges.mockChallenge1) 51 | } 52 | ) 53 | -------------------------------------------------------------------------------- /src/extensions/certbot/normalizeMeta.js: -------------------------------------------------------------------------------- 1 | const getConfig = require('../../lib/getConfig') 2 | 3 | module.exports = async ({ ellipticCurve, keyType, isTest }) => { 4 | const config = await getConfig() 5 | 6 | ellipticCurve = ellipticCurve || config.ellipticCurve 7 | keyType = keyType || config.keyType 8 | 9 | return { 10 | ellipticCurve: (keyType === 'ecdsa') ? ellipticCurve : undefined, 11 | keyType, 12 | isTest 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/extensions/certbot/normalizeMeta.test.js: -------------------------------------------------------------------------------- 1 | /* global jest test expect */ 2 | 3 | const normalizeMeta = require('./normalizeMeta') 4 | const getConfig = require('../../lib/getConfig') 5 | 6 | jest.mock('../../lib/getConfig') 7 | 8 | const mockConfig = { 9 | ellipticCurve: 'mockConfigCurve', 10 | keyType: 'ecdsa' 11 | } 12 | 13 | getConfig.mockReturnValue(mockConfig) 14 | 15 | const ellipticCurve = 'testCurve' 16 | const isTest = true 17 | const keyType = 'ecdsa' 18 | const mockMeta = { 19 | shouldNotPersist: 'should be removed', 20 | ellipticCurve, 21 | keyType, 22 | isTest 23 | } 24 | const expected = { 25 | ellipticCurve, 26 | keyType, 27 | isTest 28 | } 29 | 30 | test( 31 | 'should return object containing only relevant properties', 32 | async () => { 33 | await expect(normalizeMeta(mockMeta)).resolves.toEqual(expected) 34 | } 35 | ) 36 | 37 | test( 38 | 'should populate missing values with default config values', 39 | async () => { 40 | await expect(normalizeMeta({ isTest: false })) 41 | .resolves 42 | .toEqual({ 43 | ellipticCurve: mockConfig.ellipticCurve, 44 | isTest: false, 45 | keyType: mockConfig.keyType 46 | }) 47 | } 48 | ) 49 | 50 | test( 51 | 'should return ellipticCurve if keyType is ecdsa', 52 | async () => { 53 | await expect(normalizeMeta({ 54 | ellipticCurve, 55 | keyType: 'ecdsa' 56 | })) 57 | .resolves 58 | .toHaveProperty('ellipticCurve', ellipticCurve) 59 | } 60 | ) 61 | 62 | test( 63 | 'should return undefined ellipticCurve if keyType is not ecdsa', 64 | async () => { 65 | await expect(normalizeMeta({ 66 | ellipticCurve, 67 | keyType: 'rsa' 68 | })) 69 | .resolves 70 | .toHaveProperty('ellipticCurve', undefined) 71 | } 72 | ) 73 | -------------------------------------------------------------------------------- /src/extensions/thirdparty/config.js: -------------------------------------------------------------------------------- 1 | const defaults = { 2 | certDir: 'cache/thirdparty' 3 | } 4 | 5 | module.exports = ({ argv, env, file }) => { 6 | return { 7 | certDir: env.CERTCACHE_THIRDPARTY_DIR || 8 | file.certDir || 9 | defaults.certDir 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/extensions/thirdparty/getBundle.js: -------------------------------------------------------------------------------- 1 | const CertFinder = require('./lib/CertFinder') 2 | const getConfig = require('../../lib/getConfig') 3 | 4 | module.exports = async ({ commonName, altNames, issuerCommonName }) => { 5 | const config = (await getConfig()).extensions.thirdparty 6 | const certFinder = new CertFinder(config.certDir) 7 | const cert = await certFinder 8 | .getCert({ commonName, altNames, issuerCommonName }) 9 | const privkey = await certFinder.getKey(cert) 10 | const chain = (await certFinder.getChain(cert)) 11 | .map(({ pem }) => pem).join('') 12 | 13 | return { 14 | cert: cert.pem, 15 | chain, 16 | privkey: privkey.toPEM() 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/extensions/thirdparty/getBundle.test.js: -------------------------------------------------------------------------------- 1 | /* global jest test expect */ 2 | 3 | const getBundle = require('./getBundle') 4 | const CertFinder = require('./lib/CertFinder') 5 | 6 | jest.mock('./lib/CertFinder') 7 | jest.mock('../../lib/getConfig') 8 | 9 | const mockCertContents = '_test_cert_' 10 | const mockChainContents = ['_test_chain_1', '_test_chain_2'] 11 | const mockPrivkeyContents = '_test_privkey_' 12 | const commonName = 'test.93million.com' 13 | const altNames = [ 14 | 'test.93million.com', 15 | 'www.93million.com', 16 | 'foo.93million.com' 17 | ] 18 | const issuerCommonName = 'Test Cert Issuer' 19 | const mockCertObj = { commonName, altNames, issuerCommonName } 20 | 21 | const mockGetCert = jest.fn() 22 | const mockGetChain = jest.fn() 23 | const mockGetKey = jest.fn() 24 | 25 | mockGetCert.mockReturnValue(Promise.resolve({ 26 | pem: mockCertContents 27 | })) 28 | mockGetChain.mockReturnValue(Promise.resolve( 29 | mockChainContents.map((cert) => ({ pem: cert })) 30 | )) 31 | mockGetKey.mockReturnValue(Promise.resolve({ 32 | toPEM: () => mockPrivkeyContents 33 | })) 34 | 35 | CertFinder.mockImplementation(() => { 36 | return { 37 | getCert: mockGetCert, 38 | getChain: mockGetChain, 39 | getKey: mockGetKey 40 | } 41 | }) 42 | 43 | test( 44 | 'should return an object based on values returned from CertFinder', 45 | async () => { 46 | expect(await getBundle(mockCertObj)).toEqual({ 47 | cert: mockCertContents, 48 | chain: mockChainContents.join(''), 49 | privkey: mockPrivkeyContents 50 | }) 51 | } 52 | ) 53 | -------------------------------------------------------------------------------- /src/extensions/thirdparty/getLocalCerts.js: -------------------------------------------------------------------------------- 1 | const CertFinder = require('./lib/CertFinder') 2 | const getConfig = require('../../lib/getConfig') 3 | const Certificate = require('../../lib/classes/Certificate') 4 | const fileExists = require('../../lib/helpers/fileExists') 5 | const getCertInfoFromPem = require('../../lib/getCertInfoFromPem') 6 | 7 | let handlers 8 | 9 | const getLocalCerts = async () => { 10 | if (handlers === undefined) { 11 | handlers = require('.') 12 | } 13 | 14 | const config = (await getConfig()).extensions.thirdparty 15 | 16 | if (await fileExists(config.certDir) === false) { 17 | return [] 18 | } else { 19 | const certFinder = new CertFinder(config.certDir) 20 | 21 | return Promise.all( 22 | (await certFinder.getCerts()).map(async (cert) => { 23 | const certInfo = { 24 | ...await getCertInfoFromPem(cert.pem), 25 | certPath: cert.certPath 26 | } 27 | 28 | return new Certificate(handlers, certInfo) 29 | }) 30 | ) 31 | } 32 | } 33 | 34 | module.exports = getLocalCerts 35 | -------------------------------------------------------------------------------- /src/extensions/thirdparty/getLocalCerts.test.js: -------------------------------------------------------------------------------- 1 | /* global jest test expect beforeEach */ 2 | const getLocalCerts = require('./getLocalCerts') 3 | const CertFinder = require('./lib/CertFinder') 4 | const Certificate = require('../../lib/classes/Certificate') 5 | const fileExists = require('../../lib/helpers/fileExists') 6 | const getCertInfoFromPem = require('../../lib/getCertInfoFromPem') 7 | 8 | jest.mock('./lib/CertFinder') 9 | jest.mock('../../lib/classes/Certificate') 10 | jest.mock('../../lib/helpers/fileExists') 11 | jest.mock('../../lib/getConfig') 12 | jest.mock('../../lib/getCertInfoFromPem') 13 | 14 | const commonName = 'foo.example.com' 15 | const altNames = ['foo.example.com', 'test.example.com'] 16 | const issuerCommonName = 'test issuer' 17 | const notAfter = new Date('2019-09-27T21:55:39.114Z') 18 | const notBefore = new Date('2019-06-27T21:55:39.114Z') 19 | const certPath = '/path/to/test/cert' 20 | const cert1 = { 21 | dnsNames: altNames, 22 | certPath, 23 | subject: { commonName }, 24 | issuer: { commonName: issuerCommonName }, 25 | validTo: notAfter.toISOString(), 26 | validFrom: notBefore.toISOString() 27 | } 28 | 29 | const mockBundle = { 30 | commonName, 31 | altNames, 32 | issuerCommonName, 33 | notAfter, 34 | notBefore, 35 | certPath 36 | } 37 | const mockGetCerts = jest.fn() 38 | 39 | mockGetCerts.mockReturnValue([cert1]) 40 | getCertInfoFromPem.mockReturnValue(mockBundle) 41 | 42 | CertFinder.mockImplementation(() => ({ getCerts: mockGetCerts })) 43 | Certificate 44 | .mockImplementation((handlers, certInfo) => ({ handlers, certInfo })) 45 | 46 | beforeEach(() => { 47 | fileExists.mockReset() 48 | fileExists.mockReturnValue(Promise.resolve(true)) 49 | }) 50 | 51 | test( 52 | 'should return an array of certificates', 53 | async () => { 54 | await expect(getLocalCerts()).resolves.toEqual([{ 55 | certInfo: mockBundle, 56 | handlers: expect.any(Object) 57 | }]) 58 | } 59 | ) 60 | 61 | test( 62 | 'should return an emptry array when thirdparty certificate dir is missing', 63 | async () => { 64 | fileExists.mockReturnValue(Promise.resolve(false)) 65 | 66 | await expect(getLocalCerts()).resolves.toHaveLength(0) 67 | } 68 | ) 69 | -------------------------------------------------------------------------------- /src/extensions/thirdparty/index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | config: require('./config'), 3 | getBundle: require('./getBundle'), 4 | getLocalCerts: require('./getLocalCerts') 5 | } 6 | -------------------------------------------------------------------------------- /src/extensions/thirdparty/lib/CertFinder.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | const util = require('util') 3 | const { 4 | Certificate: X509Certificate, 5 | PrivateKey, 6 | RSAPublicKey, 7 | RSAPrivateKey 8 | } = require('@fidm/x509') 9 | const readdirRecursive = require('./readdirRecursive') 10 | const fileIsCert = require('./fileIsCert') 11 | const fileIsKey = require('./fileIsKey') 12 | const arrayItemsMatch = require('../../../lib/helpers/arrayItemsMatch') 13 | 14 | const readFile = util.promisify(fs.readFile) 15 | 16 | class CertFinder { 17 | constructor (certDir) { 18 | this.certDir = certDir 19 | } 20 | 21 | async _load () { 22 | const dirContents = await readdirRecursive(this.certDir) 23 | const fileIsCertResults = await Promise.all(dirContents.map(fileIsCert)) 24 | const fileIsKeyResults = await Promise.all(dirContents.map(fileIsKey)) 25 | const certs = dirContents.filter((path, i) => fileIsCertResults[i]) 26 | const keys = dirContents.filter((path, i) => fileIsKeyResults[i]) 27 | const pemRegexp = /-----BEGIN[^-]+-----\n[^-]+\n-----END[^-]+-----\n?/g 28 | 29 | this.certList = (await Promise.all(certs.map( 30 | async (certPath) => { 31 | const pemList = Array.from( 32 | (await readFile(certPath)).toString().match(pemRegexp) 33 | ) 34 | 35 | return pemList.map((pem) => { 36 | const cert = X509Certificate.fromPEM(pem) 37 | 38 | cert.certPath = certPath 39 | cert.pem = pem 40 | 41 | return cert 42 | }) 43 | } 44 | ))) 45 | .reduce((acc, keysInFile) => [...acc, ...keysInFile], []) 46 | this.keyList = await Promise.all(keys.map( 47 | async (keyPath) => PrivateKey.fromPEM(await readFile(keyPath)) 48 | )) 49 | } 50 | 51 | async getCert ({ altNames = [], commonName, issuerCommonName }) { 52 | return (await this.getCerts()).find(({ 53 | subject: { commonName: certCommonName }, 54 | dnsNames = [], 55 | issuer: { commonName: certIssuerCommonName } 56 | }) => ( 57 | certCommonName === commonName && 58 | arrayItemsMatch(dnsNames, altNames) && 59 | ( 60 | issuerCommonName === undefined || 61 | certIssuerCommonName === issuerCommonName 62 | ) 63 | )) 64 | } 65 | 66 | async getCerts () { 67 | if (this.certList === undefined) { 68 | await this._load() 69 | } 70 | 71 | return this.certList 72 | } 73 | 74 | async getChain (cert) { 75 | let issuer = await this.getIssuer(cert) 76 | const chain = [] 77 | 78 | while (issuer !== undefined) { 79 | chain.push(issuer) 80 | 81 | if (issuer.subject.commonName === issuer.issuer.commonName) { 82 | break 83 | } 84 | 85 | issuer = await this.getIssuer(issuer) 86 | } 87 | 88 | return chain 89 | } 90 | 91 | async getIssuer (cert) { 92 | return (await this.getCerts()).find((ca) => { 93 | return ( 94 | ca.subject.commonName === cert.issuer.commonName && 95 | cert.isIssuer(ca) 96 | ) 97 | }) 98 | } 99 | 100 | async getKey (cert) { 101 | const certPublicKey = new RSAPublicKey(cert.publicKey.toASN1()) 102 | 103 | return (await this.getKeys()).find((key) => { 104 | const keyPublicKey = new RSAPrivateKey(key.toASN1()) 105 | 106 | return (keyPublicKey.modulus === certPublicKey.modulus) 107 | }) 108 | } 109 | 110 | async getKeys () { 111 | if (this.keyList === undefined) { 112 | await this._load() 113 | } 114 | 115 | return this.keyList 116 | } 117 | } 118 | 119 | module.exports = CertFinder 120 | -------------------------------------------------------------------------------- /src/extensions/thirdparty/lib/fileIsCert.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const readFirstLine = require('./readFirstLine') 3 | 4 | module.exports = async (filePath) => { 5 | return ( 6 | ['.pem', '.cer', '.crt'].includes(path.extname(filePath)) && 7 | /^-----BEGIN CERTIFICATE-----$/.test(await readFirstLine(filePath)) 8 | ) 9 | } 10 | -------------------------------------------------------------------------------- /src/extensions/thirdparty/lib/fileIsCert.test.js: -------------------------------------------------------------------------------- 1 | /* global jest test expect */ 2 | 3 | const fileIsCert = require('./fileIsCert') 4 | const readFirstLine = require('./readFirstLine') 5 | 6 | jest.mock('./readFirstLine') 7 | 8 | const filePaths = [ 9 | { 10 | path: '/test/path/cert.crt', 11 | firstLine: '-----BEGIN CERTIFICATE-----' 12 | }, 13 | { 14 | path: '/test/path/cert.pem', 15 | firstLine: '-----BEGIN CERTIFICATE-----' 16 | }, 17 | { 18 | path: '/test/path/key.pem', 19 | firstLine: '-----BEGIN RSA PRIVATE KEY-----' 20 | }, 21 | { 22 | path: '/test/path/valid-cert-with-bad-extension.jpg', 23 | firstLine: '-----BEGIN RSA PRIVATE KEY-----' 24 | }, 25 | { 26 | path: '/test/path/invalid-cert.pem', 27 | firstLine: 'this file is not a pem' 28 | } 29 | ] 30 | 31 | readFirstLine.mockImplementation((filePath) => { 32 | return filePaths.find(({ path }) => (path === filePath)).firstLine 33 | }) 34 | 35 | test( 36 | 'should test files ending with recognised extensions', 37 | async () => { 38 | await expect(fileIsCert('/test/path/cert.crt')).resolves.toBe(true) 39 | await expect(fileIsCert('/test/path/cert.pem')).resolves.toBe(true) 40 | } 41 | ) 42 | 43 | test( 44 | 'should not test files with unrecognised extensions', 45 | async () => { 46 | await expect(fileIsCert('/test/path/valid-cert-with-bad-extension.jpg')) 47 | .resolves 48 | .toBe(false) 49 | } 50 | ) 51 | 52 | test( 53 | 'should search first line of file to see if it is a certificate', 54 | async () => { 55 | await expect(fileIsCert('/test/path/cert.pem')).resolves.toBe(true) 56 | await expect(fileIsCert('/test/path/invalid-cert.pem')).resolves.toBe(false) 57 | } 58 | ) 59 | -------------------------------------------------------------------------------- /src/extensions/thirdparty/lib/fileIsKey.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const readFirstLine = require('./readFirstLine') 3 | 4 | module.exports = async (filePath) => { 5 | return ( 6 | ['.pem', '.key'].includes(path.extname(filePath)) && 7 | /-BEGIN( RSA)? PRIVATE KEY-/.test(await readFirstLine(filePath)) 8 | ) 9 | } 10 | -------------------------------------------------------------------------------- /src/extensions/thirdparty/lib/fileIsKey.test.js: -------------------------------------------------------------------------------- 1 | /* global jest test expect */ 2 | 3 | const fileIsKey = require('./fileIsKey') 4 | const readFirstLine = require('./readFirstLine') 5 | 6 | jest.mock('./readFirstLine') 7 | 8 | const filePaths = [ 9 | { 10 | path: '/test/path/test.key', 11 | firstLine: '-----BEGIN RSA PRIVATE KEY-----' 12 | }, 13 | { 14 | path: '/test/path/key.pem', 15 | firstLine: '-----BEGIN RSA PRIVATE KEY-----' 16 | }, 17 | { 18 | path: '/test/path/cert.pem', 19 | firstLine: '-----BEGIN CERTIFICATE-----' 20 | }, 21 | { 22 | path: '/test/path/valid-cert-with-bad-extension.jpg', 23 | firstLine: '-----BEGIN RSA PRIVATE KEY-----' 24 | }, 25 | { 26 | path: '/test/path/invalid-key.pem', 27 | firstLine: 'this file is not a pem' 28 | } 29 | ] 30 | 31 | readFirstLine.mockImplementation((filePath) => { 32 | return filePaths.find(({ path }) => (path === filePath)).firstLine 33 | }) 34 | 35 | test( 36 | 'should test files ending with recognised extensions', 37 | async () => { 38 | await expect(fileIsKey('/test/path/test.key')).resolves.toBe(true) 39 | await expect(fileIsKey('/test/path/key.pem')).resolves.toBe(true) 40 | } 41 | ) 42 | 43 | test( 44 | 'should not test files with unrecognised extensions', 45 | async () => { 46 | await expect(fileIsKey('/test/path/valid-cert-with-bad-extension.jpg')) 47 | .resolves 48 | .toBe(false) 49 | } 50 | ) 51 | 52 | test( 53 | 'should search first line of file to see if it is a certificate', 54 | async () => { 55 | await expect(fileIsKey('/test/path/key.pem')).resolves.toBe(true) 56 | await expect(fileIsKey('/test/path/invalid-key.pem')).resolves.toBe(false) 57 | } 58 | ) 59 | -------------------------------------------------------------------------------- /src/extensions/thirdparty/lib/readFirstLine.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | 3 | const readFirstLine = (path) => { 4 | return new Promise(function (resolve, reject) { 5 | const readStream = fs.createReadStream(path, { encoding: 'utf8' }) 6 | const chunks = [] 7 | let pos = 0 8 | let index 9 | 10 | readStream 11 | .on('data', function (chunk) { 12 | index = chunk.indexOf('\n') 13 | chunks.push(chunk) 14 | 15 | if (index !== -1) { 16 | readStream.close() 17 | } else { 18 | pos += chunk.length 19 | } 20 | }) 21 | .on('close', function () { 22 | resolve(chunks.join('').slice(0, pos + index)) 23 | }) 24 | .on('error', function (err) { 25 | reject(err) 26 | }) 27 | }) 28 | } 29 | 30 | module.exports = readFirstLine 31 | -------------------------------------------------------------------------------- /src/extensions/thirdparty/lib/readFirstLine.test.js: -------------------------------------------------------------------------------- 1 | /* global jest test expect beforeEach */ 2 | 3 | const readFirstLine = require('./readFirstLine') 4 | const fs = require('fs') 5 | const EventEmitter = require('events') 6 | 7 | jest.mock('fs') 8 | 9 | const mockDataChunks = ['chunk 1', 'chun\nk 2', 'chunk 3', 'ch\nunk 4'] 10 | const mockFirstLine = 'chunk 1chun' 11 | 12 | let readable 13 | 14 | class MockReadStream extends EventEmitter { 15 | constructor (mockData, shouldThrow = false) { 16 | super() 17 | this.closed = false 18 | this.mockData = mockData 19 | this.shouldThrow = shouldThrow 20 | } 21 | 22 | close () { 23 | this.closed = true 24 | this.emit('close') 25 | } 26 | 27 | startData () { 28 | let chunkNum = 0 29 | const sendNextData = () => { 30 | setImmediate(() => { 31 | if (this.shouldThrow) { 32 | this.emit('error', new Error('Unable to do my job')) 33 | } 34 | if (this.closed === false) { 35 | this.emit('data', mockDataChunks[chunkNum++]) 36 | 37 | if (chunkNum === this.mockData.length) { 38 | this.emit('close') 39 | } 40 | 41 | sendNextData() 42 | } 43 | }) 44 | } 45 | 46 | sendNextData() 47 | } 48 | } 49 | 50 | beforeEach(() => { 51 | readable = new MockReadStream(mockDataChunks) 52 | 53 | fs.createReadStream.mockImplementation(() => { 54 | readable.startData() 55 | 56 | return readable 57 | }) 58 | }) 59 | 60 | test( 61 | 'should return the first line of a file', 62 | async () => { 63 | await expect(readFirstLine('/test/path/to.file')) 64 | .resolves 65 | .toBe(mockFirstLine) 66 | } 67 | ) 68 | 69 | test( 70 | 'should throw an error if an error occurs on read', 71 | async () => { 72 | const err = new Error('Unable to do my job') 73 | 74 | readable = new MockReadStream(mockDataChunks, true) 75 | 76 | await expect(readFirstLine('/test/path/to.file')) 77 | .rejects 78 | .toThrow(err) 79 | } 80 | ) 81 | -------------------------------------------------------------------------------- /src/extensions/thirdparty/lib/readdirRecursive.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | const util = require('util') 3 | const path = require('path') 4 | 5 | const readdir = util.promisify(fs.readdir) 6 | const stat = util.promisify(fs.stat) 7 | 8 | module.exports = (dirPath) => { 9 | const getDirContents = async (dirPath, fileList = []) => { 10 | const items = await readdir(dirPath) 11 | 12 | await Promise.all(items.map(async (item) => { 13 | const itemPath = path.resolve(dirPath, item) 14 | 15 | fileList.push(itemPath) 16 | 17 | if ((await stat(itemPath)).isDirectory()) { 18 | await getDirContents(itemPath, fileList) 19 | } 20 | })) 21 | 22 | return fileList 23 | } 24 | 25 | return getDirContents(dirPath) 26 | } 27 | -------------------------------------------------------------------------------- /src/extensions/thirdparty/lib/readdirRecursive.test.js: -------------------------------------------------------------------------------- 1 | /* global jest test expect */ 2 | 3 | const readdirRecursive = require('./readdirRecursive') 4 | const fs = require('fs') 5 | 6 | const filePaths = [ 7 | '/', 8 | '/testdir/', 9 | '/testdir/file1', 10 | '/testdir/file2', 11 | '/testdir/dir1/', 12 | '/testdir/dir1/file1', 13 | '/testdir/dir1/file2', 14 | '/testdir/dir1/dir2/', 15 | '/testdir/dir1/dir2/file3', 16 | '/testdir/dir1/dir2/file4', 17 | '/otherdir/', 18 | '/otherdir/otherfile1', 19 | '/otherdir/otherfile2' 20 | ] 21 | 22 | jest.mock('fs') 23 | 24 | const stripSlashes = (path) => path.replace(/\/+$/, '') 25 | 26 | fs.readdir.mockImplementation((path, callback) => { 27 | // remove trailing slashes 28 | path = stripSlashes(path) 29 | 30 | const pathComponents = path.split('/') 31 | 32 | callback( 33 | null, 34 | filePaths 35 | .filter((filePath) => { 36 | filePath = stripSlashes(filePath) 37 | const filePathComponents = filePath.split('/') 38 | 39 | return ( 40 | filePath.startsWith(`${path}/`) && 41 | filePathComponents.length === pathComponents.length + 1 42 | ) 43 | }) 44 | .map((path) => { 45 | if (path.endsWith('/')) { 46 | path = path = stripSlashes(path) 47 | } 48 | 49 | const pathComponents = path.split('/') 50 | 51 | return pathComponents[pathComponents.length - 1] 52 | }) 53 | ) 54 | }) 55 | 56 | fs.stat.mockImplementation((path, callback) => { 57 | return callback( 58 | null, 59 | { 60 | isDirectory: () => { 61 | return filePaths.includes(`${path}/`) 62 | } 63 | } 64 | ) 65 | }) 66 | 67 | test( 68 | 'should return a list of file paths', 69 | async () => { 70 | const filePath = '/testdir/' 71 | const pathRegexp = new RegExp(`^${filePath}[^/]+`) 72 | 73 | await expect(readdirRecursive(filePath)) 74 | .resolves 75 | .toEqual( 76 | filePaths 77 | .filter((path) => pathRegexp.test(path)) 78 | .map(stripSlashes) 79 | ) 80 | } 81 | ) 82 | -------------------------------------------------------------------------------- /src/lib/FeedbackError.js: -------------------------------------------------------------------------------- 1 | class FeedbackError extends Error {} 2 | 3 | module.exports = FeedbackError 4 | -------------------------------------------------------------------------------- /src/lib/__mocks__/getArgv.js: -------------------------------------------------------------------------------- 1 | /* global jest */ 2 | 3 | const getArgv = jest.fn() 4 | 5 | getArgv.mockReturnValue({ foo: 123 }) 6 | 7 | module.exports = getArgv 8 | -------------------------------------------------------------------------------- /src/lib/__mocks__/getConfig.js: -------------------------------------------------------------------------------- 1 | /* global jest */ 2 | 3 | const getConfig = jest.fn() 4 | 5 | getConfig.mockReturnValue(Promise.resolve({ 6 | binDir: 'bin', 7 | catKeysDir: '/path/to/catkeys', 8 | certDir: 'certs', 9 | certs: [ 10 | { domains: ['test.example.com'], certName: 'filecert1' }, 11 | { domains: ['foo.example.com'], certName: 'filecert2' } 12 | ], 13 | ellipticCurve: 'secp256r1', 14 | extensions: { 15 | certbot: { 16 | certbotConfigDir: '/path/to/config/dir', 17 | certbotExec: 'certbot', 18 | certbotLogsDir: '/path/to/logs/dir', 19 | certbotWorkDir: '/path/to/work/dir', 20 | defaultChallenge: 'http-01', 21 | domains: [ 22 | { 23 | domain: '~.*\\.example.com$', 24 | challenges: ['dns-01', 'http-01'] 25 | }, 26 | 'test.93million.com' 27 | ], 28 | email: 'test@example.com' 29 | }, 30 | thirdparty: { 31 | certDir: '/path/to/cert/dir' 32 | } 33 | }, 34 | httpRequestInterval: 1, 35 | keyType: 'rsa', 36 | maxRequestTime: 1234, 37 | renewalDays: 30, 38 | syncInterval: 60 * 6, 39 | server: { 40 | domainAccess: [ 41 | { 42 | domains: ['/.*\\.example.com/'], 43 | allow: ['dev', 'client'] 44 | }, 45 | { 46 | domains: ['test.example.com'], 47 | deny: ['dev'] 48 | } 49 | ] 50 | }, 51 | upstream: 'localhost' 52 | })) 53 | 54 | module.exports = getConfig 55 | -------------------------------------------------------------------------------- /src/lib/canonicaliseUpstreamConfig.js: -------------------------------------------------------------------------------- 1 | const defaults = { port: 4433 } 2 | 3 | module.exports = (upstream) => { 4 | if (typeof upstream === 'string') { 5 | const [host, port] = upstream.split(':') 6 | 7 | upstream = { host } 8 | 9 | if (port !== undefined) { 10 | upstream.port = Number(port) 11 | } 12 | } 13 | 14 | return { ...defaults, ...upstream } 15 | } 16 | -------------------------------------------------------------------------------- /src/lib/canonicaliseUpstreamConfig.test.js: -------------------------------------------------------------------------------- 1 | /* global test expect */ 2 | 3 | const canonicaliseUpstreamConfig = require('./canonicaliseUpstreamConfig') 4 | 5 | test( 6 | 'should accept a string representing hostname', 7 | () => { 8 | expect(canonicaliseUpstreamConfig('certcache1.example.com')) 9 | .toMatchObject({ host: 'certcache1.example.com' }) 10 | } 11 | ) 12 | 13 | test( 14 | 'should parse port number from hostname', 15 | () => { 16 | expect(canonicaliseUpstreamConfig('certcache1.example.com:9876')) 17 | .toMatchObject({ host: 'certcache1.example.com', port: 9876 }) 18 | } 19 | ) 20 | 21 | test( 22 | 'should accept an objects representing hostname', 23 | () => { 24 | expect(canonicaliseUpstreamConfig({ host: 'certcache1.example.com' })) 25 | .toMatchObject({ host: 'certcache1.example.com' }) 26 | } 27 | ) 28 | 29 | test( 30 | 'should return a default port when none present', 31 | () => { 32 | expect(canonicaliseUpstreamConfig('certcache1.example.com')) 33 | .toMatchObject({ host: 'certcache1.example.com', port: 4433 }) 34 | } 35 | ) 36 | 37 | test( 38 | 'should not overwrite existing port when present', 39 | () => { 40 | expect(canonicaliseUpstreamConfig({ 41 | host: 'certcache1.example.com', 42 | port: 2345 43 | })) 44 | .toMatchObject({ host: 'certcache1.example.com', port: 2345 }) 45 | } 46 | ) 47 | -------------------------------------------------------------------------------- /src/lib/classes/Certificate.js: -------------------------------------------------------------------------------- 1 | const tar = require('tar-stream') 2 | const zlib = require('zlib') 3 | const getCertInfoFromPath = require('../getCertInfoFromPath') 4 | const debug = require('debug')('certcache:certificate') 5 | 6 | class Certificate { 7 | constructor (handlers, certInfo) { 8 | this.handlers = handlers 9 | 10 | for (const i in certInfo) { 11 | this[i] = certInfo[i] 12 | } 13 | } 14 | 15 | static async fromPath (handlers, certPath) { 16 | const certInfo = await getCertInfoFromPath(certPath) 17 | 18 | return new Certificate(handlers, certInfo) 19 | } 20 | 21 | async getArchive () { 22 | const pack = tar.pack() 23 | const bundle = await this.handlers.getBundle(this) 24 | 25 | pack.entry({ name: 'cert.pem' }, bundle.cert) 26 | pack.entry({ name: 'chain.pem' }, bundle.chain) 27 | pack.entry({ name: 'privkey.pem' }, bundle.privkey) 28 | pack.finalize() 29 | 30 | return new Promise((resolve) => { 31 | const chunks = [] 32 | 33 | pack 34 | .pipe(zlib.createGzip()) 35 | .on('data', (chunk) => { 36 | chunks.push(chunk) 37 | }) 38 | .on('end', () => { 39 | debug(`created certificate archive from tar file`) 40 | resolve(Buffer.concat(chunks)) 41 | }) 42 | }) 43 | } 44 | } 45 | 46 | module.exports = Certificate 47 | -------------------------------------------------------------------------------- /src/lib/classes/Certificate.test.js: -------------------------------------------------------------------------------- 1 | /* global jest describe test expect */ 2 | 3 | const getCertInfoFromPath = require('../getCertInfoFromPath') 4 | const Certificate = require('./Certificate') 5 | const tar = require('tar-stream') 6 | const { Readable } = require('stream') 7 | const zlib = require('zlib') 8 | 9 | jest.mock('../getCertInfoFromPath') 10 | jest.mock('fs') 11 | jest.mock('rimraf') 12 | 13 | const testProp = 'foo123' 14 | const mockHandlers = { getBundle: jest.fn() } 15 | const certPath = '/test/crt.cer' 16 | const mockBundle = { 17 | cert: Buffer.from('__mockCert__'), 18 | chain: Buffer.from('__mockChain__'), 19 | privkey: Buffer.from('_mockPrivkey__') 20 | } 21 | 22 | getCertInfoFromPath.mockReturnValue(Promise.resolve({ testProp })) 23 | mockHandlers.getBundle.mockReturnValue(mockBundle) 24 | 25 | describe('creates an archive', () => { 26 | test( 27 | 'should return a tar archive of bundle as a buffer', 28 | async () => { 29 | const extract = tar.extract() 30 | const archiveStream = new Readable({ read: () => {} }) 31 | const cert = await Certificate.fromPath(mockHandlers, certPath) 32 | const archive = await cert.getArchive() 33 | const expandedFiles = {} 34 | 35 | archiveStream.push(archive) 36 | archiveStream.push(null) 37 | 38 | extract.on('entry', ({ name }, stream, next) => { 39 | const chunks = [] 40 | 41 | stream.on('data', (chunk) => { 42 | chunks.push(chunk) 43 | }) 44 | 45 | stream.on('end', () => { 46 | expandedFiles[name] = Buffer.concat(chunks) 47 | next() 48 | }) 49 | }) 50 | 51 | const receivedExpandedFiles = await new Promise((resolve, reject) => { 52 | extract.on('finish', () => { 53 | resolve(expandedFiles) 54 | }) 55 | 56 | archiveStream.pipe(zlib.createGunzip()).pipe(extract) 57 | }) 58 | 59 | expect(receivedExpandedFiles).toEqual({ 60 | 'chain.pem': mockBundle.chain, 61 | 'cert.pem': mockBundle.cert, 62 | 'privkey.pem': mockBundle.privkey 63 | }) 64 | } 65 | ) 66 | }) 67 | 68 | test( 69 | 'makes properties of getCertInfoFromPath available to be consumed', 70 | async () => { 71 | const cert = await Certificate.fromPath(mockHandlers, certPath) 72 | 73 | expect(cert.testProp).toBe(testProp) 74 | } 75 | ) 76 | -------------------------------------------------------------------------------- /src/lib/client/__mocks__/canonicaliseCertDefinitions.js: -------------------------------------------------------------------------------- 1 | /* global jest */ 2 | 3 | const canonicaliseCertDefinitions = jest.fn() 4 | 5 | canonicaliseCertDefinitions.mockReturnValue([ 6 | { 7 | certName: 'mail', 8 | domains: ['mail.example.com'], 9 | isTest: true 10 | }, 11 | { 12 | certName: 'web', 13 | domains: [ 14 | 'example.com', 15 | 'bar.example.com', 16 | 'foo.example.com', 17 | 'test.example.com', 18 | 'www.example.com' 19 | ] 20 | } 21 | ]) 22 | 23 | module.exports = canonicaliseCertDefinitions 24 | -------------------------------------------------------------------------------- /src/lib/client/canonicaliseCertDefinitions.js: -------------------------------------------------------------------------------- 1 | module.exports = (certDefinitions) => { 2 | return certDefinitions.map((item) => { 3 | if (typeof item === 'string') { 4 | item = item.split(',') 5 | } 6 | 7 | if (Array.isArray(item)) { 8 | item = { certName: item[0], domains: item } 9 | } else { 10 | if (typeof item.domains === 'string') { 11 | item.domains = item.domains.split(',') 12 | } 13 | 14 | if (item.certName === undefined) { 15 | item.certName = item.domains[0] 16 | } 17 | } 18 | 19 | item.domains = item.domains.map((domain) => domain.trim()) 20 | 21 | return item 22 | }) 23 | } 24 | -------------------------------------------------------------------------------- /src/lib/client/canonicaliseCertDefinitions.test.js: -------------------------------------------------------------------------------- 1 | /* global test expect */ 2 | 3 | const canonicaliseCertDefinitions = require('./canonicaliseCertDefinitions') 4 | 5 | const expectedDomains1 = ['example.com', 'test.example.com'] 6 | const expectedDomains2 = [ 7 | 'test2.example.com', 8 | 'test3.example.com', 9 | 'test4.example.com' 10 | ] 11 | const certName1 = 'test-cert-name' 12 | const certName2 = 'test-cert-name2' 13 | const isTest1 = false 14 | const isTest2 = true 15 | const config1 = { 16 | domains: expectedDomains1, 17 | certName: certName1, 18 | isTest: isTest1 19 | } 20 | const config2 = { 21 | domains: expectedDomains2, 22 | certName: certName2, 23 | isTest: isTest2 24 | } 25 | 26 | test( 27 | 'should handle a 1-dimensional array of comma-separated domains', 28 | () => { 29 | expect(canonicaliseCertDefinitions([ 30 | expectedDomains1.join(','), 31 | expectedDomains2.join(',') 32 | ])) 33 | .toEqual([ 34 | { 35 | domains: expectedDomains1, 36 | certName: expectedDomains1[0] 37 | }, 38 | { 39 | domains: expectedDomains2, 40 | certName: expectedDomains2[0] 41 | } 42 | ]) 43 | } 44 | ) 45 | 46 | test( 47 | 'should handle a 2-dimensional array of domains', 48 | () => { 49 | expect(canonicaliseCertDefinitions([ 50 | expectedDomains1, 51 | expectedDomains2 52 | ])) 53 | .toEqual([ 54 | { 55 | domains: expectedDomains1, 56 | certName: expectedDomains1[0] 57 | }, 58 | { 59 | domains: expectedDomains2, 60 | certName: expectedDomains2[0] 61 | } 62 | ]) 63 | } 64 | ) 65 | 66 | test( 67 | 'should handle an array of objects containins an array of domains', 68 | () => { 69 | expect(canonicaliseCertDefinitions([config1, config2])) 70 | .toEqual([ 71 | { certName: certName1, isTest: isTest1, domains: expectedDomains1 }, 72 | { certName: certName2, isTest: isTest2, domains: expectedDomains2 } 73 | ]) 74 | } 75 | ) 76 | 77 | test( 78 | 'should handle an array of objects containing comma-separated domains', 79 | () => { 80 | expect(canonicaliseCertDefinitions([ 81 | { ...config1, domains: expectedDomains1.join(',') }, 82 | { ...config2, domains: expectedDomains2.join(',') } 83 | ])) 84 | .toEqual([ 85 | { certName: certName1, isTest: isTest1, domains: expectedDomains1 }, 86 | { certName: certName2, isTest: isTest2, domains: expectedDomains2 } 87 | ]) 88 | } 89 | ) 90 | 91 | test( 92 | 'should ignore leading/trailing spaces in domains', 93 | () => { 94 | expect(canonicaliseCertDefinitions([ 95 | { ...config1, domains: expectedDomains1.join(', ') }, 96 | { ...config2, domains: [...expectedDomains2, ' foo.com '] } 97 | ])) 98 | .toEqual([ 99 | { certName: certName1, isTest: isTest1, domains: expectedDomains1 }, 100 | { 101 | certName: certName2, 102 | isTest: isTest2, 103 | domains: [...expectedDomains2, 'foo.com'] 104 | } 105 | ]) 106 | } 107 | ) 108 | 109 | test( 110 | 'should name cert when no certName property is present in object', 111 | () => { 112 | const { certName, ...config } = config1 113 | 114 | expect(canonicaliseCertDefinitions([config])) 115 | .toEqual([ 116 | { 117 | certName: expectedDomains1[0], 118 | isTest: isTest1, 119 | domains: expectedDomains1 120 | } 121 | ]) 122 | } 123 | ) 124 | -------------------------------------------------------------------------------- /src/lib/client/getCert.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const getConfig = require('../getConfig') 3 | const httpRedirect = require('../httpRedirect') 4 | const obtainCert = require('./obtainCert') 5 | const getMetaFromConfig = 6 | require('../getMetaFromExtensionFunction')('getMetaFromConfig') 7 | const canonicaliseUpstreamConfig = require('../canonicaliseUpstreamConfig') 8 | 9 | module.exports = async (opts) => { 10 | const config = (await getConfig()) 11 | const { 12 | certDir, 13 | httpRedirectUrl, 14 | renewalDays, 15 | upstream 16 | } = config 17 | const domains = opts.domains.split(',') 18 | const commonName = domains[0] 19 | const altNames = domains 20 | const certName = opts['cert-name'] || commonName 21 | const meta = await getMetaFromConfig(config) 22 | const { host, port } = canonicaliseUpstreamConfig(upstream) 23 | 24 | if (httpRedirectUrl !== undefined) { 25 | httpRedirect.start(httpRedirectUrl) 26 | } 27 | 28 | await obtainCert( 29 | host, 30 | port, 31 | commonName, 32 | altNames, 33 | meta, 34 | path.resolve(certDir, certName), 35 | { catKeysDir: config.catKeysDir, days: renewalDays } 36 | ) 37 | 38 | if (httpRedirectUrl !== undefined) { 39 | httpRedirect.stop() 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/lib/client/getCert.test.js: -------------------------------------------------------------------------------- 1 | /* global jest test expect beforeEach */ 2 | 3 | const path = require('path') 4 | const getCert = require('./getCert') 5 | const httpRedirect = require('../httpRedirect') 6 | const obtainCert = require('./obtainCert') 7 | const getConfig = (require('../getConfig')) 8 | const canonicaliseUpstreamConfig = require('../canonicaliseUpstreamConfig') 9 | 10 | jest.mock('../httpRedirect') 11 | jest.mock('./obtainCert') 12 | jest.mock('../getConfig') 13 | 14 | let mockOpts 15 | let mockConfig 16 | const mockMeta = { 17 | certbot: { isTest: expect.any(Boolean), keyType: expect.any(String) } 18 | } 19 | 20 | console.error = jest.fn() 21 | console.log = jest.fn() 22 | 23 | beforeEach(async () => { 24 | mockOpts = { 25 | domains: 'example.com,test.example.com,foo.example.com', 26 | catkeys: '/argv/path/to/catkeys', 27 | 'cert-name': 'testcert' 28 | } 29 | mockConfig = await getConfig() 30 | }) 31 | 32 | test( 33 | 'should request certs using args from command-line when provided', 34 | async () => { 35 | const mockDomainsArr = mockOpts.domains.split(',') 36 | const { host, port } = canonicaliseUpstreamConfig(mockConfig.upstream) 37 | 38 | await getCert(mockOpts) 39 | 40 | expect(obtainCert).toBeCalledWith( 41 | host, 42 | port, 43 | mockDomainsArr[0], 44 | mockDomainsArr, 45 | mockMeta, 46 | path.resolve(mockConfig.certDir, mockOpts['cert-name']), 47 | { 48 | catKeysDir: mockConfig.catKeysDir, 49 | days: mockConfig.renewalDays 50 | } 51 | ) 52 | } 53 | ) 54 | 55 | test( 56 | 'should request certs using config when no command-line args provided', 57 | async () => { 58 | mockOpts = { 59 | domains: 'example.com,test.bar.com,foo.bar.com', 60 | 'test-cert': false 61 | } 62 | 63 | await getCert(mockOpts) 64 | 65 | const mockDomainsArr = mockOpts.domains.split(',') 66 | const commonName = mockDomainsArr[0] 67 | const altNames = mockDomainsArr 68 | const { host, port } = canonicaliseUpstreamConfig(mockConfig.upstream) 69 | 70 | await getCert(mockOpts) 71 | 72 | expect(obtainCert).toBeCalledWith( 73 | host, 74 | port, 75 | commonName, 76 | altNames, 77 | mockMeta, 78 | path.resolve(mockConfig.certDir, commonName), 79 | { 80 | catKeysDir: mockConfig.catKeysDir, 81 | days: mockConfig.renewalDays 82 | } 83 | ) 84 | } 85 | ) 86 | 87 | test( 88 | 'should start an http redirect server when requested', 89 | async () => { 90 | const httpRedirectUrl = 'https://certcache.example.com' 91 | 92 | getConfig.mockReturnValueOnce({ 93 | ...mockConfig, 94 | httpRedirectUrl 95 | }) 96 | 97 | await getCert(mockOpts) 98 | 99 | expect(httpRedirect.start).toBeCalledWith(httpRedirectUrl) 100 | expect(httpRedirect.stop).toBeCalled() 101 | } 102 | ) 103 | 104 | test( 105 | 'should write cert bundle to filesystem when received from certcache server', 106 | async () => { 107 | await getCert(mockOpts) 108 | 109 | const mockDomainsArr = mockOpts.domains.split(',') 110 | const { host, port } = canonicaliseUpstreamConfig(mockConfig.upstream) 111 | 112 | expect(obtainCert) 113 | .toBeCalledWith( 114 | host, 115 | port, 116 | mockDomainsArr[0], 117 | mockDomainsArr, 118 | mockMeta, 119 | path.resolve(mockConfig.certDir, mockOpts['cert-name']), 120 | { 121 | catKeysDir: mockConfig.catKeysDir, 122 | days: mockConfig.renewalDays 123 | } 124 | ) 125 | } 126 | ) 127 | -------------------------------------------------------------------------------- /src/lib/client/obtainCert.js: -------------------------------------------------------------------------------- 1 | const request = require('../request') 2 | const writeBundle = require('../writeBundle') 3 | const getConfig = require('../getConfig') 4 | const debug = require('debug')('certcache:obtainCert') 5 | const setTimeoutPromise = require('../helpers/setTimeoutPromise') 6 | const getCert = require('../server/actions/getCert') 7 | const execCommand = require('../execCommand') 8 | 9 | module.exports = async ( 10 | host, 11 | port, 12 | commonName, 13 | altNames = [], 14 | meta, 15 | certDirPath, 16 | { catKeysDir, days, onChange } 17 | ) => { 18 | const config = await getConfig() 19 | const domains = Array.from(new Set([commonName, ...altNames])) 20 | const payload = { days, domains, meta } 21 | 22 | console.log([ 23 | `Requesting certificate CN=${commonName}`, 24 | `SAN=${JSON.stringify(domains)}`, 25 | `meta=${JSON.stringify(meta)}` 26 | ].join(' ')) 27 | 28 | const { maxRequestTime } = config 29 | const maxRequestTimePromise = setTimeoutPromise( 30 | () => { 31 | throw new Error([ 32 | 'obtainCert() took more than', 33 | maxRequestTime, 34 | 'minutes' 35 | ].join(' ')) 36 | }, 37 | 1000 * 60 * maxRequestTime 38 | ) 39 | 40 | const doRequest = () => new Promise((resolve, reject) => { 41 | const req = request( 42 | { catKeysDir, host, port }, 43 | 'getCert', 44 | { days, domains, meta } 45 | ) 46 | 47 | const requestTimeoutMs = 1000 * 60 * config.httpRequestInterval 48 | const requestTimeout = setTimeout( 49 | () => { 50 | debug([ 51 | 'Aborting and retrying request for cert - took more than', 52 | requestTimeoutMs, 53 | 'MS' 54 | ].join(' ')) 55 | 56 | req.destroy() 57 | 58 | resolve(doRequest()) 59 | }, 60 | requestTimeoutMs 61 | ) 62 | 63 | req 64 | .then((response) => { 65 | resolve(response) 66 | }) 67 | .catch((e) => { 68 | reject(e) 69 | }) 70 | .finally(() => { 71 | clearTimeout(requestTimeout) 72 | }) 73 | }) 74 | 75 | try { 76 | const { bundle } = (host === '--internal') 77 | ? await getCert(payload) 78 | : await Promise.race([doRequest(), maxRequestTimePromise]) 79 | 80 | await writeBundle(certDirPath, bundle) 81 | 82 | if (onChange !== undefined) { 83 | execCommand(onChange, { CERTCACHE_CHANGED_DIR: certDirPath }) 84 | } 85 | } catch (e) { 86 | let message = `Error renewing certificate ${certDirPath}` 87 | 88 | message += ` (${domains.join(',')}). Message: '${e.message}'` 89 | 90 | debug(`Error obtaining bundle`, e.message) 91 | 92 | throw new Error(message) 93 | } finally { 94 | maxRequestTimePromise.clearTimeout() 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/lib/client/outputInfo.js: -------------------------------------------------------------------------------- 1 | const getConfig = require('../getConfig') 2 | const getExtensions = require('../getExtensions') 3 | const packageJson = require('../../../package.json') 4 | const request = require('../request') 5 | const canonicaliseUpstreamConfig = require('../canonicaliseUpstreamConfig') 6 | 7 | const getField = ([title, val], maxTitleLength) => { 8 | return (val === undefined) 9 | ? ` ${title}` 10 | : [ 11 | ` ${title}:`.padEnd(maxTitleLength, ' '), 12 | val 13 | ].join('') 14 | } 15 | 16 | const getSection = ([title, fields], maxTitleLength) => { 17 | return [title, ...fields.map( 18 | (field) => getField(field, maxTitleLength) 19 | )] 20 | } 21 | 22 | const getInfo = async () => { 23 | const config = await getConfig() 24 | const extensions = await getExtensions() 25 | let fields = [] 26 | const sections = [] 27 | 28 | sections.push(['General', fields]) 29 | fields.push(['Version', packageJson.version]) 30 | fields.push([ 31 | 'Extensions', 32 | Object.values(extensions).map(({ id }) => id).join(', ') 33 | ]) 34 | 35 | const { upstream } = config 36 | const { host, port } = canonicaliseUpstreamConfig(upstream) 37 | 38 | fields = [] 39 | sections.push(['Upstream', fields]) 40 | 41 | if (host === '--internal') { 42 | fields.push(['Server host', 'standalone']) 43 | } else { 44 | fields.push(['Server host', host]) 45 | fields.push(['Server port', port]) 46 | try { 47 | const { catKeysDir } = config 48 | 49 | const data = await request({ catKeysDir, host, port }, 'getInfo') 50 | 51 | fields.push(['Version', data.version]) 52 | fields.push(['Extensions', data.extensions.join(', ')]) 53 | } catch (e) { 54 | fields.push(['Error connecting', e.message]) 55 | } 56 | } 57 | 58 | const maxTitleLength = Math.max( 59 | ...sections.map(([title, fields]) => Math.max( 60 | ...fields.map(([{ length }]) => length + 12) 61 | )) 62 | ) 63 | 64 | const lines = sections.reduce( 65 | (acc, section) => [...acc, ...getSection(section, maxTitleLength)], 66 | [] 67 | ) 68 | 69 | return lines.join('\n') 70 | } 71 | 72 | module.exports = async () => { 73 | console.log(await getInfo()) 74 | } 75 | -------------------------------------------------------------------------------- /src/lib/client/syncCerts.js: -------------------------------------------------------------------------------- 1 | const getLocalCertificates = require('../getLocalCertificates') 2 | const getConfig = require('../getConfig') 3 | const canonicaliseCertDefinitions = require('./canonicaliseCertDefinitions') 4 | const obtainCert = require('./obtainCert') 5 | const path = require('path') 6 | const debug = require('debug')('certcache:syncCerts') 7 | const getMetaFromCert = 8 | require('../getMetaFromExtensionFunction')('getMetaFromCert') 9 | const getMetaFromCertDefinition = 10 | require('../getMetaFromExtensionFunction')('getMetaFromCertDefinition') 11 | const canonicaliseUpstreamConfig = require('../canonicaliseUpstreamConfig') 12 | const arrayItemsMatch = require('../helpers/arrayItemsMatch') 13 | const filterAsync = require('../helpers/filterAsync') 14 | const someAsync = require('../helpers/someAsync') 15 | const metaItemsMatch = require('../helpers/metaItemsMatch') 16 | 17 | module.exports = async () => { 18 | const config = (await getConfig()) 19 | const { 20 | certDir, 21 | certs, 22 | renewalDays, 23 | upstream 24 | } = config 25 | const certcacheCertDir = path.resolve(certDir) 26 | const localCerts = await getLocalCertificates(certcacheCertDir) 27 | const certRenewEpoch = new Date() 28 | const { host, port } = canonicaliseUpstreamConfig(upstream) 29 | 30 | certRenewEpoch.setDate(certRenewEpoch.getDate() + renewalDays) 31 | 32 | const certDefinitions = canonicaliseCertDefinitions(certs) 33 | const certDefinitionsToRenew = await filterAsync( 34 | certDefinitions, 35 | async (certDefinition, i) => ( 36 | await someAsync( 37 | localCerts, 38 | async (cert) => { 39 | const { certPath, commonName, altNames = [], notAfter } = cert 40 | 41 | return ( 42 | path.basename(path.dirname(certPath)) === certDefinition.certName && 43 | commonName === certDefinition.domains[0] && 44 | ( 45 | arrayItemsMatch(altNames, certDefinition.domains) || 46 | (altNames.length === 0 && certDefinition.domains.length === 1) 47 | ) && 48 | metaItemsMatch( 49 | await getMetaFromCert(cert), 50 | await getMetaFromCertDefinition(certDefinition) 51 | ) && 52 | notAfter.getTime() >= certRenewEpoch.getTime() 53 | ) 54 | } 55 | ) === false 56 | ) 57 | ) 58 | 59 | debug('Searching for local certs in', certcacheCertDir) 60 | 61 | const certDefinitionsForRenewal = await Promise.all( 62 | certDefinitionsToRenew.map(async (certDefinition) => ({ 63 | commonName: certDefinition.domains[0], 64 | altNames: certDefinition.domains, 65 | meta: await getMetaFromCertDefinition(certDefinition), 66 | certDir: path.resolve(certDir, certDefinition.certName), 67 | onChange: certDefinition.onChange 68 | })) 69 | ) 70 | 71 | const certsForRenewal = localCerts.filter(({ certPath, notAfter }) => ( 72 | notAfter.getTime() < certRenewEpoch.getTime() && 73 | certDefinitionsForRenewal.some( 74 | ({ certDir }) => (path.dirname(certPath) === certDir) 75 | ) === false 76 | )) 77 | 78 | const certsToRequest = [ 79 | ...certDefinitionsForRenewal, 80 | ...await Promise.all(certsForRenewal.map(async (cert) => ({ 81 | ...cert, 82 | certDir: path.dirname(cert.certPath), 83 | meta: await getMetaFromCert(cert) 84 | }))) 85 | ] 86 | 87 | const obtainCertErrors = [] 88 | 89 | await Promise.all( 90 | certsToRequest.map(async ({ 91 | altNames, 92 | certDir, 93 | commonName, 94 | meta, 95 | onChange 96 | }) => { 97 | try { 98 | await obtainCert( 99 | host, 100 | port, 101 | commonName, 102 | altNames, 103 | meta, 104 | certDir, 105 | { catKeysDir: config.catKeysDir, days: renewalDays, onChange } 106 | ) 107 | } catch (e) { 108 | obtainCertErrors.push(e.message) 109 | } 110 | }) 111 | ) 112 | 113 | const numRequested = certsForRenewal.length + certDefinitionsForRenewal.length 114 | const numFailed = obtainCertErrors.length 115 | const msg = [ 116 | 'Sync complete:', 117 | numRequested, 118 | 'requested.', 119 | numRequested - numFailed, 120 | 'successful.', 121 | numFailed, 122 | 'failed.' 123 | ] 124 | 125 | console.log(msg.join(' ')) 126 | 127 | if (obtainCertErrors.length !== 0) { 128 | throw new Error(obtainCertErrors.join('\n')) 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /src/lib/client/syncPeriodically.js: -------------------------------------------------------------------------------- 1 | const getConfig = require('../../lib/getConfig') 2 | const syncCerts = require('../../lib/client/syncCerts') 3 | const httpRedirect = require('../httpRedirect') 4 | const setTimeoutPromise = require('../helpers/setTimeoutPromise') 5 | 6 | const syncPeriodically = async (forever) => { 7 | const config = (await getConfig()) 8 | let foreverPromise 9 | 10 | if (config.httpRedirectUrl !== undefined) { 11 | httpRedirect.start(config.httpRedirectUrl) 12 | } 13 | 14 | const sync = async () => { 15 | await syncCerts().catch((e) => { 16 | console.error(e) 17 | 18 | if (forever !== true) { 19 | process.exit(1) 20 | } 21 | }) 22 | 23 | if (forever === true) { 24 | foreverPromise = setTimeoutPromise(sync, 1000 * config.syncInterval * 60) 25 | 26 | await foreverPromise 27 | } else if (config.httpRedirectUrl !== undefined) { 28 | httpRedirect.stop() 29 | } 30 | } 31 | 32 | process.once('SIGTERM', () => { 33 | if (foreverPromise !== undefined) { 34 | foreverPromise.clearTimeout() 35 | } 36 | }) 37 | 38 | await sync() 39 | } 40 | 41 | module.exports = syncPeriodically 42 | -------------------------------------------------------------------------------- /src/lib/client/syncPeriodically.test.js: -------------------------------------------------------------------------------- 1 | /* global jest afterEach test expect */ 2 | 3 | const syncPeriodically = require('./syncPeriodically') 4 | const getConfig = require('../getConfig') 5 | const httpRedirect = require('../httpRedirect') 6 | const syncCerts = require('../../lib/client/syncCerts') 7 | const setTimeoutPromise = require('../helpers/setTimeoutPromise') 8 | 9 | process.exit = jest.fn() 10 | console.error = jest.fn() 11 | 12 | jest.mock('../getConfig') 13 | jest.mock('../../lib/client/syncCerts') 14 | jest.mock('../httpRedirect') 15 | jest.mock('../helpers/setTimeoutPromise') 16 | 17 | const mockTimeout = Promise.resolve() 18 | mockTimeout.clearTimeout = jest.fn() 19 | 20 | setTimeoutPromise.mockImplementation((callback) => mockTimeout) 21 | 22 | syncCerts.mockReturnValue(Promise.resolve()) 23 | 24 | const httpRedirectUrl = 'https://certcache.example.com' 25 | 26 | getConfig.mockReturnValueOnce(Promise.resolve({ 27 | httpRedirectUrl 28 | })) 29 | 30 | afterEach(() => { 31 | process.emit('SIGTERM') 32 | }) 33 | 34 | test( 35 | 'should start an http proxy when requested', 36 | async () => { 37 | await syncPeriodically() 38 | 39 | expect(httpRedirect.start).toBeCalledWith(httpRedirectUrl) 40 | } 41 | ) 42 | 43 | test( 44 | 'should log errors when syncing once', 45 | async () => { 46 | const err = new Error('barf!') 47 | 48 | syncCerts.mockReturnValueOnce(Promise.reject(err)) 49 | 50 | await syncPeriodically() 51 | 52 | expect(console.error).toBeCalledWith(err) 53 | } 54 | ) 55 | 56 | test( 57 | 'should log errors when syncing forever', 58 | async () => { 59 | const err = new Error('barf!') 60 | 61 | syncCerts.mockReturnValueOnce(Promise.reject(err)) 62 | 63 | await syncPeriodically(true) 64 | 65 | expect(console.error).toBeCalledWith(new Error('barf!')) 66 | } 67 | ) 68 | 69 | test( 70 | 'should exit with error code when not running forever', 71 | async () => { 72 | const err = new Error('barf!') 73 | 74 | syncCerts.mockReturnValueOnce(Promise.reject(err)) 75 | 76 | await syncPeriodically() 77 | 78 | expect(process.exit).toBeCalledWith(expect.any(Number)) 79 | } 80 | ) 81 | 82 | test( 83 | 'should run forever when requested', 84 | async () => { 85 | setTimeoutPromise.mockImplementationOnce((callback) => { 86 | callback() 87 | return mockTimeout 88 | }) 89 | 90 | syncPeriodically(true) 91 | 92 | await new Promise((resolve, reject) => { setImmediate(resolve) }) 93 | expect(setTimeoutPromise) 94 | .toBeCalledWith(expect.any(Function), expect.any(Number)) 95 | } 96 | ) 97 | 98 | test( 99 | 'should clear sync timeout to shut down nicely on SIGTERM', 100 | async () => { 101 | syncPeriodically(true) 102 | syncPeriodically() 103 | await new Promise((resolve, reject) => { setImmediate(resolve) }) 104 | process.emit('SIGTERM') 105 | expect(mockTimeout.clearTimeout).toBeCalledTimes(1) 106 | } 107 | ) 108 | -------------------------------------------------------------------------------- /src/lib/client/testCmd.js: -------------------------------------------------------------------------------- 1 | const getConfig = require('../getConfig') 2 | const request = require('../request') 3 | const canonicaliseUpstreamConfig = require('../canonicaliseUpstreamConfig') 4 | 5 | module.exports = async () => { 6 | const config = await getConfig() 7 | const { host, port } = canonicaliseUpstreamConfig(config.upstream) 8 | 9 | if (host === '--internal') { 10 | console.log('No upstream server. Running in standalone mode') 11 | } else { 12 | try { 13 | const { catKeysDir } = config 14 | const { version } = await request({ catKeysDir, host, port }, 'getInfo') 15 | 16 | console.log([ 17 | 'Connected successfully to server', 18 | `${host}:${port}`, 19 | 'running version', 20 | version 21 | ].join(' ')) 22 | } catch (e) { 23 | console.error('Error', e.message) 24 | process.exit(1) 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/lib/clientPermittedAccessToCerts.js: -------------------------------------------------------------------------------- 1 | const reDefinition = require('./regexps/reDefinition') 2 | 3 | module.exports = (clientCertRestrictions, clientName, domains) => ( 4 | domains.every( 5 | (certDomain) => { 6 | const matchedDomainResults = clientCertRestrictions 7 | .filter(({ domains }) => { 8 | return domains.some((domain) => { 9 | const reDomainMatch = domain.match(reDefinition) 10 | 11 | return (reDomainMatch !== null) 12 | ? new RegExp(reDomainMatch[1]).test(certDomain) 13 | : (domain === certDomain) 14 | }) 15 | }) 16 | .map(({ allow = [], deny = [] }) => ( 17 | allow.includes(clientName) === true && 18 | deny.includes(clientName) === false 19 | )) 20 | 21 | return ( 22 | matchedDomainResults.length !== 0 && 23 | matchedDomainResults.every((match) => (match === true)) 24 | ) 25 | } 26 | ) 27 | ) 28 | -------------------------------------------------------------------------------- /src/lib/clientPermittedAccessToCerts.test.js: -------------------------------------------------------------------------------- 1 | /* global test expect */ 2 | 3 | const clientPermittedAccessToCerts = require('./clientPermittedAccessToCerts') 4 | 5 | const mockClientCertRestrictions = [ 6 | { 7 | domains: [ 8 | '~^(www[1-2]\\.)?example\\.com$', 9 | '~^secure[1-2]\\.93million\\.com$' 10 | ], 11 | allow: ['deploy'] 12 | }, 13 | { 14 | domains: [ 15 | '~^test[1-2]\\.example\\.com$', 16 | '~^qa[1-2]\\.93million\\.com$' 17 | ], 18 | deny: ['dev'] 19 | }, 20 | { 21 | domains: ['~^dev[1-2]\\.example\\.com$'], 22 | allow: [ 23 | 'deploy', 24 | 'dev' 25 | ], 26 | deny: ['qa'] 27 | }, 28 | { domains: ['foo.example.com'] } 29 | ] 30 | 31 | test( 32 | 'should return true if user is in allow list for all matching domains', 33 | () => { 34 | expect(clientPermittedAccessToCerts( 35 | mockClientCertRestrictions, 36 | 'deploy', 37 | ['www1.example.com', 'dev2.example.com'] 38 | )) 39 | .toBe(true) 40 | } 41 | ) 42 | 43 | test( 44 | 'should return false if user is not in allow list for any matching domain', 45 | () => { 46 | expect(clientPermittedAccessToCerts( 47 | mockClientCertRestrictions, 48 | 'qa', 49 | ['qa1.example.com', 'secure1.93million.com'] 50 | )) 51 | .toBe(false) 52 | } 53 | ) 54 | 55 | test( 56 | 'should return false if user is in deny list for any matching domain', 57 | () => { 58 | expect(clientPermittedAccessToCerts( 59 | mockClientCertRestrictions, 60 | 'dev', 61 | ['dev1.example.com', 'qa1.93million.com'] 62 | )) 63 | .toBe(false) 64 | } 65 | ) 66 | 67 | test( 68 | 'should return false if is in neither deny or allow list for matching domain', 69 | () => { 70 | expect(clientPermittedAccessToCerts( 71 | mockClientCertRestrictions, 72 | 'dev', 73 | ['foo.example.com'] 74 | )) 75 | .toBe(false) 76 | } 77 | ) 78 | 79 | test( 80 | 'should return false for non-matching domain', 81 | () => { 82 | expect(clientPermittedAccessToCerts( 83 | mockClientCertRestrictions, 84 | 'deploy', 85 | ['www1.example.com', '93m.co.uk'] 86 | )) 87 | .toBe(false) 88 | } 89 | ) 90 | -------------------------------------------------------------------------------- /src/lib/execCommand.js: -------------------------------------------------------------------------------- 1 | const childProcess = require('child_process') 2 | const util = require('util') 3 | const path = require('path') 4 | const getConfig = require('./getConfig') 5 | 6 | const exec = util.promisify(childProcess.exec) 7 | 8 | module.exports = async (command, extraEnv) => { 9 | const { binDir } = await getConfig() 10 | const env = { 11 | ...process.env, 12 | ...extraEnv, 13 | PATH: `${process.env.PATH}:${path.resolve(binDir)}` 14 | } 15 | 16 | await exec(command, { env }) 17 | } 18 | -------------------------------------------------------------------------------- /src/lib/execCommand.test.js: -------------------------------------------------------------------------------- 1 | /* global jest test expect */ 2 | 3 | const execCommand = require('./execCommand') 4 | const childProcess = require('child_process') 5 | const path = require('path') 6 | const getConfig = require('./getConfig') 7 | 8 | jest.mock('child_process') 9 | jest.mock('./getConfig') 10 | 11 | const mockCommand = 'command 1' 12 | const mockEnv = { MOCK_ENV_1: 'mock value 1' } 13 | 14 | childProcess.exec.mockImplementation((command, options, callback) => { 15 | callback(null) 16 | }) 17 | 18 | test( 19 | 'should execute each command in commands arg array', 20 | async () => { 21 | await execCommand(mockCommand, mockEnv) 22 | 23 | expect(childProcess.exec).toBeCalledWith( 24 | mockCommand, 25 | expect.any(Object), 26 | expect.any(Function) 27 | ) 28 | } 29 | ) 30 | 31 | test( 32 | 'should extend process.env vars with extraEnv arg when calling commands', 33 | async () => { 34 | await execCommand(mockCommand, mockEnv) 35 | 36 | expect(childProcess.exec).toBeCalledWith( 37 | mockCommand, 38 | { env: { ...process.env, ...mockEnv, PATH: expect.any(String) } }, 39 | expect.any(Function) 40 | ) 41 | } 42 | ) 43 | 44 | test( 45 | 'should add config.binDir to PATH', 46 | async () => { 47 | const config = await getConfig() 48 | 49 | await execCommand(mockCommand, mockEnv) 50 | 51 | expect(childProcess.exec).toBeCalledWith( 52 | mockCommand, 53 | { 54 | env: { 55 | ...process.env, 56 | ...mockEnv, 57 | PATH: `${process.env.PATH}:${path.resolve(config.binDir)}` 58 | } 59 | }, 60 | expect.any(Function) 61 | ) 62 | } 63 | ) 64 | -------------------------------------------------------------------------------- /src/lib/generateFirstCertInSequence.js: -------------------------------------------------------------------------------- 1 | const Certificate = require('./classes/Certificate') 2 | 3 | module.exports = async (certGenerators, commonName, altNames, meta) => { 4 | return certGenerators 5 | .filter((certGenerator) => (certGenerator.generateCert !== undefined)) 6 | .reduce( 7 | async (acc, certGenerator) => ( 8 | (await acc) || 9 | Certificate.fromPath( 10 | certGenerator, 11 | await certGenerator.generateCert( 12 | commonName, 13 | altNames, 14 | meta[certGenerator.id] || {} 15 | ) 16 | ) 17 | ), 18 | Promise.resolve() 19 | ) 20 | } 21 | -------------------------------------------------------------------------------- /src/lib/generateFirstCertInSequence.test.js: -------------------------------------------------------------------------------- 1 | /* global jest test expect beforeEach */ 2 | 3 | const generateFirstCertInSequence = require('./generateFirstCertInSequence') 4 | const Certificate = require('./classes/Certificate') 5 | 6 | let parallelCalls 7 | const numParallelCalls = [] 8 | let mockCert 9 | const errorMessage = 'barf!' 10 | 11 | jest.mock('./classes/Certificate') 12 | 13 | Certificate.fromPath.mockImplementation((handlers, certPath) => { 14 | return Promise.resolve(mockCert) 15 | }) 16 | 17 | const createGeneratedCertMock = ({ shouldThrowError = false } = {}) => { 18 | const generateCert = jest.fn() 19 | 20 | generateCert.mockImplementation(() => { 21 | parallelCalls++ 22 | numParallelCalls.push(parallelCalls) 23 | parallelCalls-- 24 | 25 | if (shouldThrowError) { 26 | throw new Error(errorMessage) 27 | } 28 | 29 | return Promise.resolve('/path/to/mock/cert') 30 | }) 31 | 32 | return generateCert 33 | } 34 | 35 | const certGenerators = [ 36 | { generateCert: createGeneratedCertMock() }, 37 | { generateCert: createGeneratedCertMock() } 38 | ] 39 | const commonName = 'example.com' 40 | const altNames = ['www.example.com', 'test.example.com', 'test2.example.com'] 41 | const isTest = false 42 | const config = {} 43 | 44 | beforeEach(() => { 45 | parallelCalls = 0 46 | mockCert = { _test_: 58008 } 47 | }) 48 | 49 | test( 50 | 'should iterate sequentially over generators', 51 | async () => { 52 | await generateFirstCertInSequence( 53 | certGenerators, 54 | commonName, 55 | altNames, 56 | { isTest }, 57 | config 58 | ) 59 | 60 | expect(Math.max(...numParallelCalls)).toBe(1) 61 | } 62 | ) 63 | 64 | test( 65 | 'should return a Certificate object if cert is generated', 66 | async () => { 67 | const cert = await generateFirstCertInSequence( 68 | certGenerators, 69 | commonName, 70 | altNames, 71 | { isTest }, 72 | config 73 | ) 74 | 75 | expect(cert).toBe(mockCert) 76 | } 77 | ) 78 | 79 | test( 80 | 'should return undefined if cert cannot be generated', 81 | async () => { 82 | mockCert = undefined 83 | 84 | const cert = await generateFirstCertInSequence( 85 | certGenerators, 86 | commonName, 87 | altNames, 88 | { isTest }, 89 | config 90 | ) 91 | 92 | expect(cert).toBeUndefined() 93 | } 94 | ) 95 | -------------------------------------------------------------------------------- /src/lib/getArgv.js: -------------------------------------------------------------------------------- 1 | const yargs = require('yargs') 2 | 3 | module.exports = () => yargs.argv 4 | -------------------------------------------------------------------------------- /src/lib/getCertInfoFromPath.js: -------------------------------------------------------------------------------- 1 | const getCertInfoFromPem = require('./getCertInfoFromPem') 2 | const fs = require('fs') 3 | const util = require('util') 4 | 5 | const readFile = util.promisify(fs.readFile) 6 | 7 | module.exports = async (certPath) => { 8 | const pem = await readFile(certPath) 9 | 10 | return { 11 | ...await getCertInfoFromPem(pem), 12 | certPath 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/lib/getCertInfoFromPath.test.js: -------------------------------------------------------------------------------- 1 | /* global jest test expect */ 2 | 3 | const getCertInfoFromPath = require('./getCertInfoFromPath') 4 | const x509 = require('@fidm/x509') 5 | const fs = require('fs') 6 | const loadCert = require('./loadCert') 7 | 8 | jest.mock('@fidm/x509') 9 | jest.mock('fs') 10 | jest.mock('./loadCert') 11 | 12 | loadCert.mockReturnValue({}) 13 | 14 | const mockCert = { 15 | subject: { commonName: 'test.example.com' }, 16 | dnsNames: [ 17 | 'test.example.com', 18 | 'www1.test.example.com', 19 | 'foo.test.example.com' 20 | ], 21 | issuer: { commonName: 'Jimmy the issuer' }, 22 | validTo: new Date().toISOString(), 23 | validFrom: new Date().toISOString() 24 | } 25 | const certPath = '/test/cert/path' 26 | 27 | x509.Certificate.fromPEM.mockReturnValue(mockCert) 28 | fs.readFile.mockImplementation((path, callback) => { 29 | callback(null, 'mockedCertFileContents') 30 | }) 31 | 32 | test( 33 | 'should return information about certificate', 34 | async () => { 35 | await expect(getCertInfoFromPath(certPath)).resolves.toEqual({ 36 | altNames: mockCert.dnsNames, 37 | certPath, 38 | commonName: mockCert.subject.commonName, 39 | issuerCommonName: mockCert.issuer.commonName, 40 | notAfter: new Date(mockCert.validTo), 41 | notBefore: new Date(mockCert.validFrom) 42 | }) 43 | } 44 | ) 45 | 46 | test( 47 | 'should include EC curve when present', 48 | async () => { 49 | const nistCurve = 'mockNistCurve' 50 | 51 | loadCert.mockReturnValueOnce({ nistCurve }) 52 | await expect(getCertInfoFromPath(certPath)) 53 | .resolves 54 | .toHaveProperty('nistCurve', nistCurve) 55 | } 56 | ) 57 | 58 | test( 59 | 'should not include EC curve when not present', 60 | async () => { 61 | await expect(getCertInfoFromPath(certPath)) 62 | .resolves 63 | .toHaveProperty('nistCurve', undefined) 64 | } 65 | ) 66 | -------------------------------------------------------------------------------- /src/lib/getCertInfoFromPem.js: -------------------------------------------------------------------------------- 1 | const { Certificate } = require('@fidm/x509') 2 | const loadCert = require('./loadCert') 3 | 4 | module.exports = async (pem) => { 5 | const cert = Certificate.fromPEM(pem) 6 | 7 | const { 8 | dnsNames: altNames, 9 | issuer: { commonName: issuerCommonName }, 10 | subject: { commonName }, 11 | validFrom, 12 | validTo 13 | } = cert 14 | const { asn1Curve, nistCurve } = loadCert(pem) 15 | 16 | return { 17 | altNames, 18 | asn1Curve, 19 | commonName, 20 | issuerCommonName, 21 | nistCurve, 22 | notAfter: new Date(validTo), 23 | notBefore: new Date(validFrom) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/lib/getConfig.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const fs = require('fs') 3 | const { promisify } = require('util') 4 | const config = require('../config/config') 5 | const getExtensions = require('./getExtensions') 6 | const fileExists = require('./helpers/fileExists') 7 | const getArgv = require('./getArgv') 8 | 9 | const readFile = promisify(fs.readFile) 10 | 11 | const fileConfigPath = path.resolve(process.cwd(), 'conf', 'settings.json') 12 | 13 | let cachedConfig 14 | 15 | const load = async () => { 16 | const fileConfigBase = { extensions: {}, server: {} } 17 | const localFileConfig = await fileExists(fileConfigPath) 18 | ? JSON.parse(await readFile(fileConfigPath)) 19 | : undefined 20 | const fileConfig = (localFileConfig !== undefined) 21 | ? { ...fileConfigBase, ...localFileConfig } 22 | : fileConfigBase 23 | const extensions = await getExtensions() 24 | const argv = getArgv() 25 | const env = process.env 26 | const mainConfig = await config({ argv, env, file: fileConfig }) 27 | 28 | const extensionConfigs = Object.keys(extensions).reduce( 29 | (acc, key) => { 30 | if (extensions[key].config !== undefined) { 31 | const file = fileConfig.extensions[key] || {} 32 | 33 | acc[key] = extensions[key].config({ argv, env, file }) 34 | } 35 | 36 | return acc 37 | }, 38 | {} 39 | ) 40 | 41 | return { ...mainConfig, extensions: extensionConfigs } 42 | } 43 | 44 | module.exports = async ({ noCache } = {}) => { 45 | if (cachedConfig === undefined || noCache === true) { 46 | cachedConfig = await load() 47 | } 48 | 49 | return cachedConfig 50 | } 51 | -------------------------------------------------------------------------------- /src/lib/getConfig.test.js: -------------------------------------------------------------------------------- 1 | /* global jest expect test */ 2 | 3 | const getConfig = require('./getConfig') 4 | const fs = require('fs') 5 | const fileExists = require('./helpers/fileExists') 6 | const getExtensions = require('./getExtensions') 7 | const mainConfigFn = require('../config/config') 8 | 9 | jest.mock('fs') 10 | jest.mock('./helpers/fileExists') 11 | jest.mock('./getExtensions') 12 | jest.mock('../config/config') 13 | jest.mock('./getArgv') 14 | 15 | const mockFileConfig = { 16 | test1: 123, 17 | test2: 456, 18 | test3: { foo: 123 }, 19 | test4: { bar: 345 } 20 | } 21 | const baseFileConfig = { extensions: {}, server: {} } 22 | const mockMainConfig = { 23 | mainItem1: 123, 24 | mainItem2: 'def' 25 | } 26 | const mockFileBaseCombined = { 27 | ...baseFileConfig, 28 | ...mockFileConfig 29 | } 30 | 31 | fs.readFile.mockImplementation((path, callback) => { 32 | callback(null, JSON.stringify(mockFileConfig)) 33 | }) 34 | 35 | mainConfigFn.mockReturnValue(mockMainConfig) 36 | 37 | const mockExtensionConfig = { ext1: 'abd', ext2: { test1: 'def' } } 38 | 39 | const mockExtensionConfigFn = jest.fn() 40 | mockExtensionConfigFn.mockReturnValue(mockExtensionConfig) 41 | 42 | const mockExtensions = { 43 | ext1: { config: mockExtensionConfigFn }, 44 | extWithoutConfig: {} 45 | } 46 | getExtensions.mockReturnValue(Promise.resolve(mockExtensions)) 47 | 48 | fileExists.mockReturnValue(Promise.resolve(true)) 49 | 50 | test( 51 | 'local file configs should extend a base structure', 52 | async () => { 53 | expect(await getConfig()).toMatchObject(mockMainConfig) 54 | } 55 | ) 56 | 57 | test( 58 | 'should pass argv, env and file based configs to global config functions', 59 | async () => { 60 | await getConfig({ noCache: true }) 61 | 62 | expect(mainConfigFn).toBeCalledWith({ 63 | argv: expect.any(Object), 64 | env: process.env, 65 | file: mockFileBaseCombined 66 | }) 67 | } 68 | ) 69 | 70 | test( 71 | 'should use a base config structure when no file based config exists', 72 | async () => { 73 | fileExists.mockReturnValueOnce(false) 74 | 75 | await getConfig({ noCache: true }) 76 | 77 | expect(mainConfigFn).toBeCalledWith({ 78 | argv: expect.any(Object), 79 | env: process.env, 80 | file: baseFileConfig 81 | }) 82 | } 83 | ) 84 | 85 | test( 86 | 'should pass argv, env and file based configs to extension config functions', 87 | async () => { 88 | await getConfig({ noCache: true }) 89 | 90 | expect(mockExtensionConfigFn).toBeCalledWith({ 91 | argv: expect.any(Object), 92 | env: process.env, 93 | file: {} 94 | }) 95 | } 96 | ) 97 | 98 | test( 99 | 'should skip extensions that do not provide config functions', 100 | async () => { 101 | const config = await getConfig({ noCache: true }) 102 | 103 | expect(config).toMatchObject({ 104 | extensions: { ext1: expect.any(Object) } 105 | }) 106 | 107 | expect(config).not.toMatchObject({ 108 | extensions: { extWithoutConfig: expect.any(Object) } 109 | }) 110 | } 111 | ) 112 | 113 | test( 114 | 'should structure extension configs inside extensions object', 115 | async () => { 116 | expect(await getConfig({ noCache: true })).toMatchObject({ 117 | extensions: { ext1: expect.any(Object) } 118 | }) 119 | } 120 | ) 121 | 122 | test( 123 | 'should cache results of getConfig() for reuse', 124 | async () => { 125 | await getConfig({ noCache: true }) 126 | await getConfig() 127 | 128 | expect(mainConfigFn).toBeCalledTimes(1) 129 | expect(mockExtensionConfigFn).toBeCalledTimes(1) 130 | } 131 | ) 132 | -------------------------------------------------------------------------------- /src/lib/getExtensions.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | const path = require('path') 3 | const { promisify } = require('util') 4 | const requireModule = require('./helpers/requireModule') 5 | 6 | const readdir = promisify(fs.readdir) 7 | const stat = promisify(fs.stat) 8 | const extensionsDir = path.resolve(__dirname, '..', 'extensions') 9 | 10 | let extensions 11 | 12 | module.exports = async ({ noCache } = {}) => { 13 | if (extensions === undefined || noCache === true) { 14 | extensions = (await readdir(extensionsDir)).reduce( 15 | async (acc, filename) => { 16 | if ((await stat(path.resolve(extensionsDir, filename))).isDirectory()) { 17 | const extension = requireModule(path.resolve( 18 | extensionsDir, 19 | filename 20 | )); 21 | 22 | (await acc)[filename] = { ...extension, id: filename } 23 | } 24 | 25 | return acc 26 | }, 27 | Promise.resolve({}) 28 | ) 29 | } 30 | 31 | return extensions 32 | } 33 | -------------------------------------------------------------------------------- /src/lib/getExtensions.test.js: -------------------------------------------------------------------------------- 1 | /* global jest test expect */ 2 | 3 | const fs = require('fs') 4 | const path = require('path') 5 | const getExtensions = require('./getExtensions') 6 | const requireModule = require('./helpers/requireModule') 7 | 8 | jest.mock('fs') 9 | jest.mock('./helpers/requireModule') 10 | 11 | const mockDirContents = ['ext1', 'ext2', 'fileToIgnore'] 12 | const ext1 = { foo: 'baa' } 13 | const ext2 = { baa: 'fee' } 14 | const mockExtensions = { ext1, ext2 } 15 | const extensionsDir = path.resolve(__dirname, '..', 'extensions') 16 | 17 | requireModule.mockImplementation((_path) => { 18 | const key = Object.keys(mockExtensions).find((key) => { 19 | return (_path === path.resolve(extensionsDir, key)) 20 | }) 21 | 22 | return key && mockExtensions[key] 23 | }) 24 | 25 | fs.readdir.mockImplementation((path, callback) => { 26 | callback(null, mockDirContents) 27 | }) 28 | 29 | fs.stat.mockImplementation(async (_path, callback) => { 30 | callback( 31 | null, 32 | { 33 | isDirectory: () => { 34 | return (path.basename(_path).startsWith('file') === false) 35 | } 36 | } 37 | ) 38 | }) 39 | 40 | test( 41 | 'should generate list of extensions from directory contents', 42 | async () => { 43 | await expect(getExtensions()).resolves.toEqual({ 44 | ext1: { ...ext1, id: expect.any(String) }, 45 | ext2: { ...ext2, id: expect.any(String) } 46 | }) 47 | } 48 | ) 49 | 50 | test( 51 | 'should add generated id inside extension object', 52 | async () => { 53 | await expect(getExtensions()).resolves.toEqual({ 54 | ext1: { ...ext1, id: 'ext1' }, 55 | ext2: { ...ext2, id: 'ext2' } 56 | }) 57 | } 58 | ) 59 | 60 | test( 61 | 'should only include items that are directories', 62 | async () => { 63 | await expect(getExtensions()).resolves.not.toHaveProperty('fileToIgnore') 64 | } 65 | ) 66 | 67 | test( 68 | 'should cache results', 69 | async () => { 70 | await getExtensions({ noCache: true }) 71 | await getExtensions() 72 | 73 | expect(requireModule) 74 | .toHaveBeenCalledTimes(Object.keys(mockExtensions).length) 75 | } 76 | ) 77 | -------------------------------------------------------------------------------- /src/lib/getExtensionsForDomains.js: -------------------------------------------------------------------------------- 1 | const getExtensions = require('./getExtensions') 2 | 3 | module.exports = async (domains) => { 4 | return Object.values(await getExtensions()).reduce( 5 | async (acc, extension) => ( 6 | extension.canGenerateDomains === undefined || 7 | await extension.canGenerateDomains(domains) 8 | ) 9 | ? [...(await acc), extension] 10 | : acc, 11 | Promise.resolve([]) 12 | ) 13 | } 14 | -------------------------------------------------------------------------------- /src/lib/getExtensionsForDomains.test.js: -------------------------------------------------------------------------------- 1 | /* global jest test expect */ 2 | 3 | const getExtensionsForDomains = require('./getExtensionsForDomains') 4 | const getExtensions = require('./getExtensions') 5 | 6 | jest.mock('./getConfig') 7 | jest.mock('./getExtensions') 8 | 9 | const canGenerateDomains = jest.fn() 10 | const canNotGenerateDomains = jest.fn() 11 | 12 | canGenerateDomains.mockReturnValue(Promise.resolve(true)) 13 | canNotGenerateDomains.mockReturnValue(Promise.resolve(false)) 14 | 15 | const mockExtensions = { 16 | ext1: { id: 'ext1', canGenerateDomains: canGenerateDomains }, 17 | ext2: { id: 'ext2', canGenerateDomains: canNotGenerateDomains }, 18 | ext3: { id: 'ext3' } 19 | } 20 | 21 | getExtensions.mockReturnValue(Promise.resolve(mockExtensions)) 22 | 23 | test( 24 | 'should return a list of extensions that can generate certs for domains', 25 | async () => { 26 | const certGenerators = await getExtensionsForDomains([ 27 | 'foo.example.com', 28 | 'test.93million.com' 29 | ]) 30 | 31 | expect(certGenerators.map(({ id }) => id)).toEqual(['ext1', 'ext3']) 32 | } 33 | ) 34 | -------------------------------------------------------------------------------- /src/lib/getLocalCertificates.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | const getCertInfoFromPath = require('./getCertInfoFromPath') 3 | const util = require('util') 4 | const fileExists = require('./helpers/fileExists') 5 | 6 | const readdir = util.promisify(fs.readdir) 7 | 8 | module.exports = async (certDir) => { 9 | const dirItems = await readdir(certDir).catch(() => []) 10 | const certPaths = dirItems.map((item) => `${certDir}/${item}/cert.pem`) 11 | const existsArr = await Promise.all(certPaths.map(fileExists)) 12 | 13 | return Promise.all(certPaths 14 | .filter((certPath, i) => existsArr[i]) 15 | .map(async (certPath) => ({ 16 | ...await getCertInfoFromPath(certPath), 17 | certPath 18 | })) 19 | ) 20 | } 21 | -------------------------------------------------------------------------------- /src/lib/getLocalCertificates.test.js: -------------------------------------------------------------------------------- 1 | /* global jest test expect */ 2 | 3 | const fs = require('fs') 4 | const getCertInfoFromPath = require('./getCertInfoFromPath') 5 | const fileExists = require('./helpers/fileExists') 6 | const getLocalCertificates = require('./getLocalCertificates') 7 | 8 | jest.mock('fs') 9 | jest.mock('./getCertInfoFromPath') 10 | jest.mock('./helpers/fileExists') 11 | 12 | const certDir = '/test/certs' 13 | const certDirItems = ['cert1', 'cert2', 'cert3', 'README.txt'] 14 | const filePaths = [ 15 | `${certDir}/cert1/cert.pem`, 16 | `${certDir}/cert2/cert.pem`, 17 | `${certDir}/cert3/cert.pem`, 18 | `${certDir}/README.txt` 19 | ] 20 | 21 | fs.readdir.mockImplementation((path, callback) => { 22 | callback( 23 | (path === certDir) 24 | ? null 25 | : { ...new Error(`ENOENT not found ${path}`) }, 26 | (path === certDir) ? certDirItems : undefined 27 | ) 28 | }) 29 | 30 | const mockCert = { _test_: 58008 } 31 | 32 | getCertInfoFromPath.mockReturnValue(Promise.resolve(mockCert)) 33 | fileExists.mockImplementation((path) => filePaths.includes(path)) 34 | 35 | test('should get local certificates', async () => { 36 | const expected = [ 37 | { ...mockCert, certPath: `${certDir}/cert1/cert.pem` }, 38 | { ...mockCert, certPath: `${certDir}/cert2/cert.pem` }, 39 | { ...mockCert, certPath: `${certDir}/cert3/cert.pem` } 40 | ] 41 | 42 | const localCerts = getLocalCertificates(certDir) 43 | 44 | await expect(localCerts).resolves.toEqual(expected) 45 | }) 46 | 47 | test( 48 | 'should return a blank array when certificate diretcory doesn\'t exist', 49 | async () => { 50 | await expect(getLocalCertificates('/dir/that/doesnt/exist')) 51 | .resolves 52 | .toHaveLength(0) 53 | } 54 | ) 55 | -------------------------------------------------------------------------------- /src/lib/getMetaFromExtensionFunction.js: -------------------------------------------------------------------------------- 1 | const getExtensions = require('./getExtensions') 2 | 3 | module.exports = (fnName) => async (syncItem) => { 4 | const extensions = await getExtensions() 5 | 6 | return Object.keys(extensions).reduce( 7 | async (acc, key) => { 8 | const extension = extensions[key] 9 | const meta = extension[fnName] && await extension[fnName](syncItem) 10 | 11 | if (meta !== undefined) { 12 | (await acc)[key] = meta 13 | } 14 | 15 | return acc 16 | }, 17 | Promise.resolve({}) 18 | ) 19 | } 20 | -------------------------------------------------------------------------------- /src/lib/helpers/allItemsPresent.js: -------------------------------------------------------------------------------- 1 | const reDefinition = require('../regexps/reDefinition') 2 | 3 | module.exports = (items, searchList) => { 4 | return ( 5 | searchList !== undefined && 6 | items.every( 7 | (item) => { 8 | return searchList.some((searchItem) => { 9 | const reSearch = searchItem.match(reDefinition) 10 | 11 | return (reSearch !== null) 12 | ? new RegExp(reSearch[1]).test(item) 13 | : searchItem === item 14 | }) 15 | } 16 | ) 17 | ) 18 | } 19 | -------------------------------------------------------------------------------- /src/lib/helpers/arrayItemsMatch.js: -------------------------------------------------------------------------------- 1 | module.exports = (a1, a2) => { 2 | return ( 3 | a1.length === a2.length && 4 | a1.every((item, i) => a2.includes(item)) && 5 | a2.every((item, i) => a1.includes(item)) 6 | ) 7 | } 8 | -------------------------------------------------------------------------------- /src/lib/helpers/arrayItemsMatch.test.js: -------------------------------------------------------------------------------- 1 | /* global test expect */ 2 | 3 | const arrayItemsMatch = require('./arrayItemsMatch') 4 | 5 | const srcList = ['93million.com', '93m.co.uk', 'www.93m.co.uk'] 6 | 7 | test( 8 | 'should return true for matching items regardless of their order', 9 | () => { 10 | expect(arrayItemsMatch( 11 | srcList, 12 | ['www.93m.co.uk', '93million.com', '93m.co.uk'] 13 | )) 14 | .toBe(true) 15 | } 16 | ) 17 | test( 18 | 'should return false when extra items are present', 19 | () => { 20 | expect(arrayItemsMatch( 21 | srcList, 22 | ['www.93m.co.uk', '93million.com', '93m.co.uk', 'example.com'] 23 | )) 24 | .toBe(false) 25 | } 26 | ) 27 | test( 28 | 'should return false when duplicate items present with missing item', 29 | () => { 30 | expect(arrayItemsMatch( 31 | srcList, 32 | ['www.93m.co.uk', '93m.co.uk', '93m.co.uk'] 33 | )) 34 | .toBe(false) 35 | } 36 | ) 37 | -------------------------------------------------------------------------------- /src/lib/helpers/concurrencyLimiter.js: -------------------------------------------------------------------------------- 1 | module.exports = (fn, concurrency) => { 2 | const stack = [] 3 | let inFlightNum = 0 4 | 5 | return function () { 6 | const _args = arguments 7 | 8 | return new Promise((resolve, reject) => { 9 | const callNext = () => { 10 | while (inFlightNum < concurrency && stack.length !== 0) { 11 | const callback = stack.shift() 12 | 13 | inFlightNum++ 14 | callback() 15 | } 16 | } 17 | const complete = (val) => { 18 | inFlightNum-- 19 | 20 | callNext() 21 | 22 | resolve(val) 23 | } 24 | const error = (val) => { 25 | inFlightNum-- 26 | 27 | callNext() 28 | 29 | reject(val) 30 | } 31 | const callback = () => { 32 | Promise.resolve(fn(..._args)).then(complete, error) 33 | } 34 | 35 | stack.push(callback) 36 | 37 | callNext() 38 | }) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/lib/helpers/concurrencyLimiter.test.js: -------------------------------------------------------------------------------- 1 | /* global jest test expect */ 2 | 3 | const concurrencyLimiter = require('./concurrencyLimiter') 4 | 5 | test( 6 | 'should limit the number of concurrent callbacks', 7 | async () => { 8 | let inProgress = 0 9 | const asyncFn = () => new Promise((resolve) => { 10 | inProgress++ 11 | setTimeout(resolve, 0) 12 | }) 13 | const limitedFn = concurrencyLimiter(asyncFn, 2) 14 | Promise.all([ 15 | limitedFn(), 16 | limitedFn(), 17 | limitedFn(), 18 | limitedFn() 19 | ]) 20 | 21 | expect(inProgress).toBe(2) 22 | } 23 | ) 24 | 25 | test( 26 | 'should resolve with value of promise resolved by each invokation', 27 | async () => { 28 | const asyncFn = (value) => new Promise((resolve) => { 29 | setTimeout(() => { resolve(value * 2) }, 0) 30 | }) 31 | const limitedFn = concurrencyLimiter(asyncFn, 2) 32 | 33 | await expect(limitedFn(2)).resolves.toBe(4) 34 | await expect(limitedFn(3)).resolves.toBe(6) 35 | await expect(limitedFn(4)).resolves.toBe(8) 36 | await expect(limitedFn(5)).resolves.toBe(10) 37 | } 38 | ) 39 | 40 | test( 41 | 'should continue to resolve following rejected promises', 42 | async () => { 43 | let count = 0 44 | const asyncFn = (shouldThrow = false) => new Promise((resolve, reject) => { 45 | setTimeout(() => { 46 | if (shouldThrow) { 47 | reject(new Error('doh!')) 48 | } else { 49 | count++ 50 | resolve() 51 | } 52 | }, 0) 53 | }) 54 | const _catch = jest.fn() 55 | const limitedFn = concurrencyLimiter(asyncFn, 2) 56 | 57 | await Promise.all([ 58 | limitedFn(), 59 | limitedFn(true).catch(_catch), 60 | limitedFn(), 61 | limitedFn() 62 | ]) 63 | 64 | expect(count).toBe(3) 65 | expect(_catch).toBeCalledTimes(1) 66 | } 67 | ) 68 | 69 | test( 70 | 'should pass all arguments to wrapped function', 71 | async () => { 72 | const asyncFn = (a, b, c) => new Promise((resolve) => { 73 | setTimeout(() => { resolve(a + b * c) }, 0) 74 | }) 75 | const limitedFn = concurrencyLimiter(asyncFn, 2) 76 | 77 | await expect(limitedFn(2, 2, 3)).resolves.toBe(8) 78 | await expect(limitedFn(4, 2, 4)).resolves.toBe(12) 79 | await expect(limitedFn(7, 2, 3)).resolves.toBe(13) 80 | await expect(limitedFn(6, 4, 2)).resolves.toBe(14) 81 | } 82 | ) 83 | 84 | test( 85 | 'should work with synchronous functions', 86 | async () => { 87 | const syncFn = (a, b, c) => a + b * c 88 | const limitedFn = concurrencyLimiter(syncFn, 2) 89 | 90 | await expect(limitedFn(2, 2, 3)).resolves.toBe(8) 91 | await expect(limitedFn(4, 2, 4)).resolves.toBe(12) 92 | await expect(limitedFn(7, 2, 3)).resolves.toBe(13) 93 | await expect(limitedFn(6, 4, 2)).resolves.toBe(14) 94 | } 95 | ) 96 | -------------------------------------------------------------------------------- /src/lib/helpers/fileExists.js: -------------------------------------------------------------------------------- 1 | const util = require('util') 2 | const fs = require('fs') 3 | const stat = util.promisify(fs.stat) 4 | 5 | module.exports = (path) => stat(path).then(() => true).catch((e) => false) 6 | -------------------------------------------------------------------------------- /src/lib/helpers/fileExists.test.js: -------------------------------------------------------------------------------- 1 | /* global jest test expect */ 2 | 3 | const fs = require('fs') 4 | const fileExists = require('./fileExists') 5 | 6 | jest.mock('fs') 7 | 8 | const filePaths = ['/test/file/exists'] 9 | 10 | fs.stat.mockImplementation((path, callback) => { 11 | const pathExists = filePaths.includes(path) 12 | 13 | callback( 14 | pathExists 15 | ? null 16 | : { 17 | ...new Error(`ENOENT: no such file or directory, stat '${path}'`), 18 | code: 'ENOENT', 19 | path, 20 | syscall: 'stat' 21 | }, 22 | pathExists ? { size: 123 } : undefined 23 | ) 24 | }) 25 | 26 | test( 27 | 'should return a promise that resolves to true if file exists', 28 | async () => { 29 | await expect(fileExists('/test/file/exists')).resolves.toBe(true) 30 | } 31 | ) 32 | 33 | test( 34 | 'should return a promise that resolves to false if file doesn\'t exist', 35 | async () => { 36 | await expect(fileExists('/test/file/no/existy')).resolves.toBe(false) 37 | } 38 | ) 39 | -------------------------------------------------------------------------------- /src/lib/helpers/filterAsync.js: -------------------------------------------------------------------------------- 1 | module.exports = async (searchArray, callback) => { 2 | const filterResults = await Promise.all( 3 | searchArray.map(callback) 4 | ) 5 | 6 | return searchArray.filter((_, i) => filterResults[i]) 7 | } 8 | -------------------------------------------------------------------------------- /src/lib/helpers/filterAsync.test.js: -------------------------------------------------------------------------------- 1 | /* global jest test expect */ 2 | 3 | const filterAsync = require('./filterAsync') 4 | 5 | const testArr = [1, 2, 3, 4, 5] 6 | 7 | test( 8 | 'should filter results using an async function', 9 | async () => { 10 | const callback = (item) => Promise.resolve(item > 2) 11 | 12 | await expect(filterAsync(testArr, callback)) 13 | .resolves 14 | .toEqual(testArr.slice(2)) 15 | } 16 | ) 17 | 18 | test( 19 | 'should pass expected args to callback function', 20 | async () => { 21 | const callback = jest.fn() 22 | const index = 2 23 | const element = testArr[index] 24 | 25 | await filterAsync(testArr, callback) 26 | 27 | expect(callback.mock.calls[2]).toEqual([element, index, testArr]) 28 | } 29 | ) 30 | -------------------------------------------------------------------------------- /src/lib/helpers/metaItemsMatch.js: -------------------------------------------------------------------------------- 1 | const sortObjectProperties = require('../helpers/sortObjectProperties') 2 | 3 | module.exports = (meta1, meta2) => { 4 | meta1 = sortObjectProperties(meta1) 5 | meta2 = sortObjectProperties(meta2) 6 | 7 | return (JSON.stringify(meta1) === JSON.stringify(meta2)) 8 | } 9 | -------------------------------------------------------------------------------- /src/lib/helpers/metaItemsMatch.test.js: -------------------------------------------------------------------------------- 1 | /* global test expect */ 2 | 3 | const metaItemsMatch = require('./metaItemsMatch') 4 | 5 | test( 6 | 'should return true when meta items match', 7 | () => { 8 | const meta1 = { test: { item: 'foo', bar: 123 } } 9 | const meta2 = { test: { item: 'foo', bar: 123 } } 10 | 11 | expect(metaItemsMatch(meta1, meta2)).toBe(true) 12 | } 13 | ) 14 | 15 | test( 16 | 'should return true when meta items do not match', 17 | () => { 18 | const meta1 = { test: { item: 'foo', bar: 123 } } 19 | const meta2 = { test: { item: 'bar', bar: 321 } } 20 | 21 | expect(metaItemsMatch(meta1, meta2)).toBe(false) 22 | } 23 | ) 24 | 25 | test( 26 | 'should match items regardless of property order', 27 | () => { 28 | const meta1 = { test: { item: 'foo', bar: 123 } } 29 | const meta2 = { test: { bar: 123, item: 'foo' } } 30 | 31 | expect(metaItemsMatch(meta1, meta2)).toBe(true) 32 | } 33 | ) 34 | -------------------------------------------------------------------------------- /src/lib/helpers/mkdirRecursive.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | const util = require('util') 3 | const mkdir = util.promisify(fs.mkdir) 4 | const fileExists = require('./fileExists') 5 | 6 | module.exports = async (path) => { 7 | const dirsArr = path.split('/').filter((item) => (item !== '')) 8 | const searchPaths = dirsArr.map((path, i, dirs) => ( 9 | `/${[...dirs].splice(0, i + 1).join('/')}` 10 | )) 11 | 12 | return searchPaths.reduce( 13 | async (acc, path) => ( 14 | (await fileExists(path) === false) 15 | ? acc.then(() => 16 | mkdir(path).catch((e) => { 17 | if (e.code !== 'EEXIST') { 18 | throw e 19 | } 20 | }) 21 | ) 22 | : acc 23 | ), 24 | Promise.resolve() 25 | ) 26 | } 27 | -------------------------------------------------------------------------------- /src/lib/helpers/mkdirRecursive.test.js: -------------------------------------------------------------------------------- 1 | /* global jest test expect beforeEach */ 2 | 3 | const mkdirRecursive = require('./mkdirRecursive') 4 | const fs = require('fs') 5 | const fileExists = require('./fileExists') 6 | 7 | jest.mock('fs') 8 | jest.mock('./fileExists') 9 | 10 | let dirList 11 | 12 | fileExists.mockImplementation((path) => dirList.includes(path)) 13 | 14 | fs.mkdir.mockImplementation((path, callback) => { 15 | const dirPath = path.split('/') 16 | dirPath.shift() 17 | 18 | const parentDir = `/${dirPath.slice(0, dirPath.length - 1).join('/')}` 19 | const parentDirExists = dirList.includes(parentDir) 20 | let err 21 | 22 | if (parentDirExists === false) { 23 | err = new Error(`ENOENT: no such file or directory, mkdir '${path}'`) 24 | err.code = 'ENOENT' 25 | err.path = parentDir 26 | err.syscall = 'stat' 27 | } else if (dirList.includes(path)) { 28 | err = new Error(`EEXIST: file already exists, mkdir '${path}'`) 29 | err.code = 'EEXIST' 30 | err.path = path 31 | err.syscall = 'mkdir' 32 | } else { 33 | dirList.push(path) 34 | } 35 | 36 | callback( 37 | (err === undefined) ? null : err, 38 | parentDirExists ? true : undefined 39 | ) 40 | }) 41 | 42 | beforeEach(() => { 43 | dirList = [ 44 | '/', 45 | '/dir1', 46 | '/dir1/subdir1', 47 | '/dir1/subdir2' 48 | ] 49 | }) 50 | 51 | test( 52 | 'should call mkdir recursively until all missing directories are created', 53 | async () => { 54 | await mkdirRecursive('/dir1/subdir2/test123/test456') 55 | 56 | expect(fs.mkdir.mock.calls).toEqual([ 57 | ['/dir1/subdir2/test123', expect.any(Function)], 58 | ['/dir1/subdir2/test123/test456', expect.any(Function)] 59 | ]) 60 | } 61 | ) 62 | 63 | test( 64 | 'should catch file exists (EEXIST) errors when calling mkdir', 65 | async () => { 66 | fileExists.mockImplementation((path) => false) 67 | 68 | await expect(mkdirRecursive('/dir1/subdir2/test123/test456')) 69 | .resolves 70 | .not 71 | .toThrow() 72 | } 73 | ) 74 | 75 | test( 76 | 'should not catch errors other than file exists (EEXIST) when calling mkdir', 77 | async () => { 78 | fileExists 79 | .mockImplementation((path) => path !== '/dir1/subdir2/test123/test456') 80 | 81 | await expect(mkdirRecursive('/dir1/subdir2/test123/test456')) 82 | .rejects 83 | .toThrow() 84 | } 85 | ) 86 | -------------------------------------------------------------------------------- /src/lib/helpers/requireModule.js: -------------------------------------------------------------------------------- 1 | module.exports = (path) => require(path) 2 | -------------------------------------------------------------------------------- /src/lib/helpers/setAndDemandDirPerms.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | const util = require('util') 3 | 4 | const chmod = util.promisify(fs.chmod) 5 | const chown = util.promisify(fs.chown) 6 | const stat = util.promisify(fs.stat) 7 | 8 | const setAndDemandDirPerms = async ( 9 | dir, 10 | { gid = 0, uid = 0, perms = 0o700 } = {} 11 | ) => { 12 | const permsMask = 0o777 13 | const ownerIsValid = (stat) => (stat.uid === uid && stat.gid === gid) 14 | const permsAreValid = (stat) => ((stat.mode & permsMask ^ perms) === 0) 15 | 16 | let parentDirStat = await stat(dir) 17 | 18 | if (!ownerIsValid(parentDirStat) || !permsAreValid(parentDirStat)) { 19 | if (!ownerIsValid(parentDirStat)) { 20 | await chown(dir, uid, gid) 21 | } 22 | 23 | if (!permsAreValid(parentDirStat)) { 24 | await chmod(dir, perms) 25 | } 26 | 27 | parentDirStat = await stat(dir) 28 | 29 | if (!ownerIsValid(parentDirStat)) { 30 | throw new Error([ 31 | 'Directory', 32 | dir, 33 | 'has incorrect user id/group id. Should be uid =', 34 | uid, 35 | 'gid =', 36 | gid, 37 | 'but is uid =', 38 | parentDirStat.uid, 39 | 'gid =', 40 | parentDirStat.gid 41 | ].join(' ')) 42 | } 43 | 44 | if (!permsAreValid(parentDirStat)) { 45 | throw new Error([ 46 | 'Directory', 47 | dir, 48 | 'has incorrect permissions. Should be', 49 | (parentDirStat.mode & permsMask).toString(8), 50 | 'but is', 51 | perms.toString(8) 52 | ].join(' ')) 53 | } 54 | } 55 | } 56 | 57 | module.exports = setAndDemandDirPerms 58 | -------------------------------------------------------------------------------- /src/lib/helpers/setAndDemandDirPerms.test.js: -------------------------------------------------------------------------------- 1 | /* global jest test expect */ 2 | 3 | const setAndDemandDirPerms = require('./setAndDemandDirPerms') 4 | const fs = require('fs') 5 | 6 | const mockDir = '/test/mock/dir' 7 | const mockUid = 12 8 | const mockGid = 23 9 | const mockPerms = 0o700 10 | const mockStat = { gid: mockGid, mode: 0o40700, uid: mockUid } 11 | const mockOpts = { gid: mockGid, perms: mockPerms, uid: mockUid } 12 | 13 | jest.mock('fs') 14 | 15 | fs.stat.mockImplementation((path, callback) => { callback(null, mockStat) }) 16 | 17 | fs.chown.mockImplementation((path, uid, gid, callback) => { 18 | callback(null) 19 | }) 20 | 21 | fs.chmod.mockImplementation((path, mode, callback) => { 22 | callback(null) 23 | }) 24 | 25 | test( 26 | 'should resolve when file permissions are as expected', 27 | async () => { 28 | await expect(setAndDemandDirPerms(mockDir, mockOpts)).resolves.not.toThrow() 29 | } 30 | ) 31 | 32 | test( 33 | 'should run with default opts', 34 | async () => { 35 | fs.stat.mockImplementationOnce((path, callback) => { 36 | callback(null, { ...mockStat, uid: 0, gid: 0 }) 37 | }) 38 | await expect(setAndDemandDirPerms(mockDir)).resolves.not.toThrow() 39 | } 40 | ) 41 | 42 | test( 43 | 'should change owners for directories with incorrect owners', 44 | async () => { 45 | fs.stat.mockImplementationOnce((path, callback) => { 46 | callback(null, { ...mockStat, mode: 0o40640 }) 47 | }) 48 | 49 | await setAndDemandDirPerms(mockDir, mockOpts) 50 | 51 | expect(fs.chmod).toBeCalledWith(mockDir, mockPerms, expect.any(Function)) 52 | } 53 | ) 54 | 55 | test( 56 | 'should change permissions for directories with incorrect permissions', 57 | async () => { 58 | fs.stat.mockImplementationOnce((path, callback) => { 59 | callback(null, { ...mockStat, uid: 45, gid: 67 }) 60 | }) 61 | 62 | await setAndDemandDirPerms(mockDir, mockOpts) 63 | 64 | expect(fs.chown) 65 | .toBeCalledWith(mockDir, mockUid, mockGid, expect.any(Function)) 66 | } 67 | ) 68 | 69 | test( 70 | 'should throw error if directories have incorrect owners', 71 | async () => { 72 | for (let i = 0; i < 2; i++) { 73 | fs.stat.mockImplementationOnce((path, callback) => { 74 | callback(null, { ...mockStat, mode: 0o40640 }) 75 | }) 76 | } 77 | 78 | await expect(setAndDemandDirPerms(mockDir, mockOpts)) 79 | .rejects 80 | .toThrow([ 81 | 'Directory /test/mock/dir has incorrect permissions.', 82 | 'Should be 640 but is 700' 83 | ].join(' ')) 84 | } 85 | ) 86 | 87 | test( 88 | 'should throw error if directories have incorrect permissions', 89 | async () => { 90 | for (let i = 0; i < 2; i++) { 91 | fs.stat.mockImplementationOnce((path, callback) => { 92 | callback(null, { ...mockStat, uid: 45, gid: 67 }) 93 | }) 94 | } 95 | 96 | await expect(setAndDemandDirPerms(mockDir, mockOpts)) 97 | .rejects 98 | .toThrow([ 99 | 'Directory /test/mock/dir has incorrect user id/group id.', 100 | 'Should be uid = 12 gid = 23 but is uid = 45 gid = 67' 101 | ].join(' ')) 102 | } 103 | ) 104 | -------------------------------------------------------------------------------- /src/lib/helpers/setTimeoutPromise.js: -------------------------------------------------------------------------------- 1 | module.exports = (callback, ms) => { 2 | let timeout 3 | 4 | const promise = new Promise((resolve, reject) => { 5 | timeout = setTimeout( 6 | () => { 7 | try { 8 | resolve(callback()) 9 | } catch (e) { 10 | reject(e) 11 | } 12 | }, 13 | ms 14 | ) 15 | }) 16 | 17 | promise.clearTimeout = () => { 18 | clearTimeout(timeout) 19 | } 20 | 21 | return promise 22 | } 23 | -------------------------------------------------------------------------------- /src/lib/helpers/setTimeoutPromise.test.js: -------------------------------------------------------------------------------- 1 | /* global jest test expect */ 2 | 3 | const setTimeoutPromise = require('./setTimeoutPromise') 4 | 5 | const mockCallback = jest.fn() 6 | const mockCallbackReturnValue = 4321 7 | const mockTimeoutMs = 1 8 | const mockTimeoutId = 123 9 | 10 | mockCallback.mockReturnValue(mockCallbackReturnValue) 11 | global.setTimeout = jest.fn() 12 | global.setTimeout.mockImplementation((callback) => { 13 | callback() 14 | return mockTimeoutId 15 | }) 16 | global.clearTimeout = jest.fn() 17 | 18 | test( 19 | 'should return a promise', 20 | () => { 21 | expect(setTimeoutPromise(mockCallback, mockTimeoutMs)) 22 | .toStrictEqual(expect.any(Promise)) 23 | } 24 | ) 25 | 26 | test( 27 | 'should resolve with the value returned by the callback', 28 | async () => { 29 | await expect(setTimeoutPromise(mockCallback, mockTimeoutMs)) 30 | .resolves 31 | .toBe(mockCallbackReturnValue) 32 | } 33 | ) 34 | 35 | test( 36 | 'should call setTimeout with callback function and timeout', 37 | () => { 38 | setTimeoutPromise(mockCallback, mockTimeoutMs) 39 | 40 | expect(global.setTimeout) 41 | .toBeCalledWith(expect.any(Function), mockTimeoutMs) 42 | expect(mockCallback).toBeCalled() 43 | } 44 | ) 45 | 46 | test( 47 | 'should be able to clear timeout from promise', 48 | () => { 49 | const promise = setTimeoutPromise(mockCallback, mockTimeoutMs) 50 | 51 | promise.clearTimeout() 52 | 53 | expect(global.clearTimeout).toBeCalledWith(mockTimeoutId) 54 | } 55 | ) 56 | -------------------------------------------------------------------------------- /src/lib/helpers/someAsync.js: -------------------------------------------------------------------------------- 1 | module.exports = (searchArray, callback) => { 2 | return searchArray.reduce( 3 | async (acc, cur, idx, src) => { 4 | return (await acc) || callback(cur, idx, src) 5 | }, 6 | Promise.resolve(false) 7 | ) 8 | } 9 | -------------------------------------------------------------------------------- /src/lib/helpers/someAsync.test.js: -------------------------------------------------------------------------------- 1 | /* global jest test expect */ 2 | const someAsync = require('./someAsync') 3 | 4 | const testArr = [1, 2, 3, 4] 5 | 6 | test( 7 | 'should return true when callback returns true for 1 or more items', 8 | async () => { 9 | const callback = (item) => Promise.resolve(item === 3) 10 | 11 | expect(await someAsync(testArr, callback)).toBe(true) 12 | } 13 | ) 14 | 15 | test( 16 | 'should return false when callback returns false for all items', 17 | async () => { 18 | const callback = (item) => Promise.resolve(item === 5) 19 | 20 | expect(await someAsync(testArr, callback)).toBe(false) 21 | } 22 | ) 23 | 24 | test( 25 | 'should pass expected args to callback function', 26 | async () => { 27 | const callback = jest.fn() 28 | const index = 2 29 | const element = testArr[index] 30 | 31 | await someAsync(testArr, callback) 32 | 33 | expect(callback.mock.calls[2]).toEqual([element, index, testArr]) 34 | } 35 | ) 36 | -------------------------------------------------------------------------------- /src/lib/helpers/sortObjectProperties.js: -------------------------------------------------------------------------------- 1 | const sortObjectProperties = (obj) => { 2 | return Array.isArray(obj) 3 | ? [...obj].sort().map((item) => sortObjectProperties(item)) 4 | : (typeof obj === 'object') 5 | ? (Object.keys(obj)).sort().reduce( 6 | (acc, key) => { 7 | acc[key] = sortObjectProperties(obj[key]) 8 | 9 | return acc 10 | }, 11 | {} 12 | ) 13 | : obj 14 | } 15 | 16 | module.exports = sortObjectProperties 17 | -------------------------------------------------------------------------------- /src/lib/helpers/sortObjectProperties.test.js: -------------------------------------------------------------------------------- 1 | /* global test expect */ 2 | 3 | const sortObjectProperties = require('./sortObjectProperties') 4 | 5 | test( 6 | 'should sort object by properties alphabetically', 7 | () => { 8 | const unsorted = { foo: 123, bar: 321 } 9 | const expected = { bar: 321, foo: 123 } 10 | const sorted = sortObjectProperties(unsorted) 11 | 12 | expect(JSON.stringify(sorted)).toBe(JSON.stringify(expected)) 13 | } 14 | ) 15 | 16 | test( 17 | 'should not mutate supplied objects', 18 | () => { 19 | const unsorted = { foo: 123, bar: 321 } 20 | const sorted = sortObjectProperties(unsorted) 21 | 22 | expect(JSON.stringify(sorted)).not.toBe(JSON.stringify(unsorted)) 23 | } 24 | ) 25 | 26 | test( 27 | 'should not mutate supplied arrays', 28 | () => { 29 | const unsorted = [3, 2, 1] 30 | const sorted = sortObjectProperties(unsorted) 31 | 32 | expect(JSON.stringify(sorted)).not.toBe(JSON.stringify(unsorted)) 33 | } 34 | ) 35 | 36 | test( 37 | 'should recursively sort structured objects', 38 | () => { 39 | const unsorted = { foo: 123, bar: { b: 321, a: 123 } } 40 | const expected = { bar: { a: 123, b: 321 }, foo: 123 } 41 | const sorted = sortObjectProperties(unsorted) 42 | 43 | expect(JSON.stringify(sorted)).toBe(JSON.stringify(expected)) 44 | } 45 | ) 46 | 47 | test( 48 | 'should recursively sort structured arrays', 49 | () => { 50 | const unsorted = { foo: 123, bar: ['c', 'a', 'b'] } 51 | const expected = { bar: ['a', 'b', 'c'], foo: 123 } 52 | const sorted = sortObjectProperties(unsorted) 53 | 54 | expect(JSON.stringify(sorted)).toBe(JSON.stringify(expected)) 55 | } 56 | ) 57 | 58 | test( 59 | 'should accept and sort arrays', 60 | () => { 61 | const unsorted = ['c', 'a', 'b'] 62 | const expected = ['a', 'b', 'c'] 63 | const sorted = sortObjectProperties(unsorted) 64 | 65 | expect(JSON.stringify(sorted)).toBe(JSON.stringify(expected)) 66 | } 67 | ) 68 | -------------------------------------------------------------------------------- /src/lib/httpRedirect.js: -------------------------------------------------------------------------------- 1 | const http = require('http') 2 | 3 | let redirectServer 4 | 5 | module.exports.start = (httpRedirectUrl) => { 6 | if (httpRedirectUrl.endsWith('/')) { 7 | httpRedirectUrl = httpRedirectUrl.substring(0, httpRedirectUrl.length - 1) 8 | } 9 | 10 | redirectServer = http.createServer((req, res) => { 11 | if (req.url.startsWith('/.well-known/')) { 12 | res.writeHead(302, { Location: `${httpRedirectUrl}${req.url}` }) 13 | } 14 | 15 | res.end() 16 | }).listen(80) 17 | } 18 | 19 | const stop = () => { 20 | if (redirectServer !== undefined) { 21 | redirectServer.close() 22 | redirectServer = undefined 23 | } 24 | } 25 | 26 | process.on('SIGTERM', () => stop()) 27 | 28 | module.exports.stop = stop 29 | -------------------------------------------------------------------------------- /src/lib/httpRedirect.test.js: -------------------------------------------------------------------------------- 1 | /* global jest afterEach test expect */ 2 | 3 | const httpRedirect = require('./httpRedirect') 4 | const http = require('http') 5 | 6 | jest.mock('http') 7 | 8 | const listen = jest.fn() 9 | const close = jest.fn() 10 | let requestHandler 11 | 12 | listen.mockReturnValue({ close }) 13 | 14 | http.createServer.mockImplementation((handler) => { 15 | requestHandler = handler 16 | 17 | return { listen } 18 | }) 19 | 20 | const res = { writeHead: jest.fn(), end: jest.fn() } 21 | const redirectUrl = 'http://example.com' 22 | const requestPath = '/.well-known/anything/' 23 | 24 | afterEach(() => { 25 | httpRedirect.stop() 26 | }) 27 | 28 | test( 29 | 'should start an http server on port 80', 30 | () => { 31 | httpRedirect.start(redirectUrl) 32 | 33 | expect(http.createServer).toHaveBeenCalledTimes(1) 34 | expect(listen).toHaveBeenCalledTimes(1) 35 | expect(listen).toBeCalledWith(80) 36 | } 37 | ) 38 | 39 | test( 40 | 'should redirect requests to paths starting with \'/.well-known/\'', 41 | () => { 42 | httpRedirect.start(redirectUrl) 43 | requestHandler({ url: requestPath }, res) 44 | 45 | expect(res.writeHead).toHaveBeenCalledTimes(1) 46 | expect(res.writeHead).toBeCalledWith(302, { 47 | Location: `${redirectUrl}${requestPath}` 48 | }) 49 | expect(res.end).toHaveBeenCalledTimes(1) 50 | } 51 | ) 52 | 53 | test( 54 | 'should not redirect requests to paths not starting with \'/.well-known/\'', 55 | () => { 56 | httpRedirect.start(redirectUrl) 57 | requestHandler({ url: '/test/path/' }, res) 58 | 59 | expect(res.writeHead).not.toHaveBeenCalled() 60 | expect(res.end).toHaveBeenCalledTimes(1) 61 | } 62 | ) 63 | 64 | test( 65 | 'should to redirect URLs ending with and without forward slashes equally', 66 | () => { 67 | httpRedirect.start(redirectUrl) 68 | requestHandler({ url: requestPath }, res) 69 | 70 | httpRedirect.start(`${redirectUrl}/`) 71 | requestHandler({ url: requestPath }, res) 72 | 73 | expect(res.writeHead).toHaveBeenCalledTimes(2) 74 | expect(res.end).toHaveBeenCalledTimes(2) 75 | 76 | const expectedLocation = `${redirectUrl}${requestPath}` 77 | 78 | expect(res.writeHead.mock.calls[0][1]).toEqual({ 79 | Location: expectedLocation 80 | }) 81 | 82 | expect(res.writeHead.mock.calls[1][1]).toEqual({ 83 | Location: expectedLocation 84 | }) 85 | } 86 | ) 87 | 88 | test( 89 | 'should stop the http server when requested to', 90 | () => { 91 | httpRedirect.stop() 92 | httpRedirect.start(redirectUrl) 93 | httpRedirect.stop() 94 | 95 | expect(close).toHaveBeenCalledTimes(1) 96 | } 97 | ) 98 | -------------------------------------------------------------------------------- /src/lib/listCerts.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const getExtensions = require('./getExtensions') 3 | const getLocalCertificates = require('./getLocalCertificates') 4 | const getConfig = require('./getConfig') 5 | 6 | const outputCertInfo = ({ 7 | altNames, 8 | certPath, 9 | commonName, 10 | issuerCommonName, 11 | notAfter, 12 | notBefore 13 | }) => { 14 | console.log(`Path: ${certPath}`) 15 | console.log(`Common name: ${commonName}`) 16 | console.log(`Alt names: ${altNames.join(',')}`) 17 | console.log(`Issuer: ${issuerCommonName}`) 18 | console.log(`Start date: ${notBefore}`) 19 | console.log(`End date: ${notAfter}`) 20 | console.log('') 21 | } 22 | 23 | module.exports = async (opts) => { 24 | const extensions = await getExtensions() 25 | const config = await getConfig() 26 | const locators = Object.keys(extensions) 27 | const filteredLocators = (opts.extensions === undefined) 28 | ? locators 29 | : locators 30 | .filter((extension) => opts.extensions.split(',').includes(extension)) 31 | const localCerts = await Promise.all( 32 | filteredLocators.map((locator) => extensions[locator].getLocalCerts()) 33 | ) 34 | const clientCerts = await getLocalCertificates(path.resolve(config.certDir)) 35 | const div = '======================' 36 | 37 | filteredLocators.forEach((locator, i) => { 38 | console.log(`${div}\nExtension: ${locator}\n${div}`) 39 | localCerts[i].forEach((cert) => { 40 | outputCertInfo(cert) 41 | }) 42 | console.log('\n') 43 | }) 44 | 45 | console.log(`${div}\nClient certs\n${div}`) 46 | clientCerts.forEach((cert) => { 47 | outputCertInfo(cert) 48 | }) 49 | } 50 | -------------------------------------------------------------------------------- /src/lib/loadCert.js: -------------------------------------------------------------------------------- 1 | const tls = require('tls') 2 | const net = require('net') 3 | 4 | module.exports = (cert) => { 5 | const secureContext = tls.createSecureContext({ cert }) 6 | const secureSocket = new tls.TLSSocket(new net.Socket(), { secureContext }) 7 | 8 | return secureSocket.getCertificate() 9 | } 10 | -------------------------------------------------------------------------------- /src/lib/loadCert.test.js: -------------------------------------------------------------------------------- 1 | /* global jest test expect */ 2 | 3 | const loadCert = require('./loadCert') 4 | const tls = require('tls') 5 | 6 | jest.mock('tls') 7 | jest.mock('net') 8 | 9 | const mockCert = 'mock_cert' 10 | const getCertificate = jest.fn() 11 | 12 | tls.TLSSocket.mockImplementation(() => ({ getCertificate })) 13 | 14 | test( 15 | 'should generate a certificate using tls.TLSSocket', 16 | () => { 17 | loadCert(mockCert) 18 | expect(getCertificate).toBeCalledTimes(1) 19 | } 20 | ) 21 | -------------------------------------------------------------------------------- /src/lib/normalizeMeta.js: -------------------------------------------------------------------------------- 1 | const getExtensions = require('./getExtensions') 2 | 3 | module.exports = async (meta) => { 4 | const extensions = await getExtensions() 5 | 6 | return Object.values(extensions).reduce(async (acc, extension) => { 7 | acc = await acc 8 | 9 | if (extension.normalizeMeta !== undefined) { 10 | acc[extension.id] = await extension.normalizeMeta(acc[extension.id] || {}) 11 | } 12 | 13 | return acc 14 | }, { ...meta }) 15 | } 16 | -------------------------------------------------------------------------------- /src/lib/normalizeMeta.test.js: -------------------------------------------------------------------------------- 1 | /* global jest test expect */ 2 | 3 | const normalizeMeta = require('./normalizeMeta') 4 | const getExtensions = require('./getExtensions') 5 | 6 | jest.mock('./getExtensions') 7 | 8 | const mockExtensions = { 9 | ext1: { 10 | id: 'ext1', 11 | normalizeMeta: ({ item1, item3 }) => ({ 12 | item1: item3, 13 | item2: item1 14 | }) 15 | }, 16 | ext3: { 17 | id: 'ext3', 18 | normalizeMeta: ({ item1, item2 }) => ({ 19 | item1: item2, 20 | item3: item1 21 | }) 22 | }, 23 | extWithoutMeta: { 24 | id: 'ext4', 25 | normalizeMeta: jest.fn() 26 | } 27 | } 28 | 29 | const metaItem = { 30 | item1: 'item1', 31 | item2: 'item2', 32 | item3: 'item3', 33 | item4: 'item4' 34 | } 35 | const mockMeta = { ext1: metaItem, ext2: metaItem, ext3: metaItem } 36 | 37 | getExtensions.mockReturnValue(mockExtensions) 38 | 39 | test( 40 | 'should replace items in extension that define normalizeMeta', 41 | async () => { 42 | await expect(normalizeMeta(mockMeta)).resolves.toMatchObject({ 43 | ext1: { 44 | item1: 'item3', 45 | item2: 'item1' 46 | }, 47 | ext3: { 48 | item1: 'item2', 49 | item3: 'item1' 50 | } 51 | }) 52 | } 53 | ) 54 | 55 | test( 56 | 'should not replace items in extension that do not define normalizeMeta', 57 | async () => { 58 | await expect(normalizeMeta(mockMeta)) 59 | .resolves 60 | .toHaveProperty('ext2', metaItem) 61 | } 62 | ) 63 | 64 | test( 65 | // eslint-disable-next-line max-len 66 | 'should call extension.normalizeMeta with empty object when no correspond object exists in meta', 67 | async () => { 68 | await normalizeMeta(mockMeta) 69 | expect(mockExtensions.extWithoutMeta.normalizeMeta).toBeCalledWith({}) 70 | } 71 | ) 72 | 73 | test( 74 | // eslint-disable-next-line max-len 75 | 'should include objects returned from extension.normalizeMeta when no correspond object exists in meta', 76 | async () => { 77 | await normalizeMeta(mockMeta) 78 | expect(mockExtensions.extWithoutMeta.normalizeMeta).toBeCalledWith({}) 79 | } 80 | ) 81 | -------------------------------------------------------------------------------- /src/lib/regexps/reDefinition.js: -------------------------------------------------------------------------------- 1 | module.exports = /^~(.+)$/ 2 | -------------------------------------------------------------------------------- /src/lib/request.js: -------------------------------------------------------------------------------- 1 | const { https } = require('catkeys') 2 | const debug = require('debug')('certcache:request') 3 | 4 | module.exports = ( 5 | { host, port, catKeysDir }, 6 | action, 7 | payload = {} 8 | ) => { 9 | const postData = JSON.stringify({ action, ...payload }) 10 | const options = { 11 | catRejectMismatchedHostname: false, 12 | catKeysDir: catKeysDir, 13 | headers: { 'Content-Length': Buffer.from(postData).length }, 14 | hostname: host, 15 | method: 'POST', 16 | path: '/', 17 | port 18 | } 19 | 20 | let _req 21 | let isDestroyed = false 22 | const promise = new Promise((resolve, reject) => { 23 | const response = [] 24 | 25 | return https 26 | .request(options, (res) => { 27 | res.on('data', (data) => response.push(data)) 28 | res.on('end', () => { 29 | const res = response.join('') 30 | 31 | debug('request() response length', res.length) 32 | 33 | const responseObj = JSON.parse(res) 34 | 35 | if (responseObj.success === true) { 36 | resolve(responseObj.data) 37 | } else { 38 | reject(new Error(responseObj.error)) 39 | } 40 | }) 41 | }) 42 | .then((req) => { 43 | _req = req 44 | 45 | if (isDestroyed) { 46 | req.destroy() 47 | } else { 48 | req.on('error', (e) => { 49 | reject(e) 50 | }) 51 | 52 | debug('request() request', options) 53 | debug('request() posting', postData) 54 | req.write(postData) 55 | req.end() 56 | 57 | return req 58 | } 59 | }) 60 | }) 61 | 62 | promise.destroy = () => { 63 | isDestroyed = true 64 | 65 | if (_req !== undefined) { 66 | _req.destroy() 67 | } 68 | } 69 | 70 | return promise 71 | } 72 | -------------------------------------------------------------------------------- /src/lib/request.test.js: -------------------------------------------------------------------------------- 1 | /* global jest test expect beforeEach */ 2 | 3 | const request = require('./request') 4 | const { https } = require('catkeys') 5 | const { Readable, Writable } = require('stream') 6 | 7 | const host = 'certcache.example.com' 8 | const port = 12345 9 | const action = 'doSomething' 10 | const domains = ['secure.example.com', 'secret.example.com'] 11 | const meta = { isTest: true } 12 | const mockResponse = { data: 'test certcache response data', success: true } 13 | let requestData 14 | const mockErrorMessage = '__test error message__' 15 | let requestStream 16 | 17 | jest.mock('catkeys') 18 | 19 | const setUpRequestMockImplementation = ({ 20 | shouldThrow = false, 21 | response = mockResponse 22 | } = {}) => { 23 | https.request.mockReset() 24 | https.request.mockImplementation((options, callback) => { 25 | const requestDataArr = [] 26 | const responseStream = new Readable({ read: () => {} }) 27 | requestStream = new Writable({ 28 | write: (chunk, encoding, callback) => { 29 | requestDataArr.push(chunk) 30 | callback() 31 | } 32 | }) 33 | 34 | requestStream.on('finish', () => { 35 | if (shouldThrow) { 36 | requestStream.emit('error', new Error(mockErrorMessage)) 37 | } else { 38 | requestData = requestDataArr.join('') 39 | responseStream.push(JSON.stringify(response)) 40 | responseStream.push(null) 41 | } 42 | }) 43 | setImmediate(() => { callback(responseStream) }) 44 | 45 | return Promise.resolve(requestStream) 46 | }) 47 | } 48 | 49 | beforeEach(() => { 50 | setUpRequestMockImplementation() 51 | }) 52 | 53 | test( 54 | 'should send a request for the certificate to the certcache server', 55 | async () => { 56 | await request({ host, port }, action, { domains, meta }) 57 | 58 | expect(JSON.parse(requestData)) 59 | .toEqual({ action, domains, meta }) 60 | } 61 | ) 62 | 63 | test( 64 | 'should return the data returned by the certcache server in a promise', 65 | async () => { 66 | const response = await request({ host, port }, action, { domains, meta }) 67 | 68 | expect(response).toEqual(mockResponse.data) 69 | } 70 | ) 71 | 72 | test( 73 | 'should throw an error if an error is returned by the request library', 74 | async () => { 75 | setUpRequestMockImplementation({ shouldThrow: true }) 76 | 77 | await expect(request({ host, port }, { domains, meta })) 78 | .rejects 79 | .toThrow() 80 | } 81 | ) 82 | 83 | test( 84 | 'should be able to be destroyed before request made', 85 | () => { 86 | expect.assertions(1) 87 | const req = request({ host, port }, { domains, meta }) 88 | 89 | req.destroy() 90 | 91 | req.finally(() => { 92 | throw new Error([ 93 | 'request promise should not be resolved or rejected after being', 94 | 'destroyed' 95 | ].join(' ')) 96 | }) 97 | 98 | return new Promise((resolve) => { 99 | setImmediate(() => { 100 | expect(requestStream.destroyed).toBe(true) 101 | resolve() 102 | }) 103 | }) 104 | } 105 | ) 106 | 107 | test( 108 | 'should be able to be destroyed after request made', 109 | async () => { 110 | const req = request({ host, port }, { domains, meta }) 111 | 112 | await new Promise((resolve) => { 113 | process.nextTick(() => { 114 | req.destroy() 115 | resolve() 116 | }) 117 | }) 118 | 119 | expect(requestStream.destroyed).toBe(true) 120 | } 121 | ) 122 | 123 | test( 124 | 'should throw errors before being destroyed', 125 | async () => { 126 | const req = request({ host, port }, { domains, meta }) 127 | 128 | process.nextTick(() => { 129 | const err = new Error('BAAAAH!') 130 | err.code = 'ECONNRESET' 131 | requestStream.emit('error', err) 132 | }) 133 | 134 | await expect(req).rejects.toThrow('BAAAAH!') 135 | } 136 | ) 137 | 138 | test( 139 | 'should throw an error when response was not successful', 140 | async () => { 141 | const error = 'nope!' 142 | setUpRequestMockImplementation({ response: { success: false, error } }) 143 | 144 | await expect(request({ host, port }, { domains, meta })) 145 | .rejects 146 | .toThrow(error) 147 | } 148 | ) 149 | -------------------------------------------------------------------------------- /src/lib/server/actions/__snapshots__/getInfo.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`should output data to match snapshot 1`] = ` 4 | Object { 5 | "extensions": Array [ 6 | "certbot", 7 | "thirdparty", 8 | ], 9 | "version": "0.6.0", 10 | } 11 | `; 12 | -------------------------------------------------------------------------------- /src/lib/server/actions/getCert.js: -------------------------------------------------------------------------------- 1 | const generateFirstCertInSequence = require('../../generateFirstCertInSequence') 2 | const FeedbackError = require('../../FeedbackError') 3 | const debug = require('debug')('certcache:server/actions/getCert') 4 | const clientPermittedAccessToCerts = 5 | require('../../clientPermittedAccessToCerts') 6 | const arrayItemsMatch = require('../../helpers/arrayItemsMatch') 7 | const getExtensionsForDomains = require('../../getExtensionsForDomains') 8 | const getConfig = require('../../getConfig') 9 | const metaItemsMatch = require('../../helpers/metaItemsMatch') 10 | const normalizeMeta = require('../../normalizeMeta') 11 | const getMetaFromCert = 12 | require('../../getMetaFromExtensionFunction')('getMetaFromCert') 13 | 14 | const checkRestrictions = async (clientName, domains) => { 15 | const config = await getConfig() 16 | 17 | if ( 18 | config.server.domainAccess !== undefined && 19 | clientPermittedAccessToCerts( 20 | config.server.domainAccess, 21 | clientName, 22 | domains 23 | ) === false 24 | ) { 25 | throw new FeedbackError([ 26 | 'Client', 27 | clientName, 28 | 'does not have permission to generate the requested certs' 29 | ].join(' ')) 30 | } 31 | } 32 | 33 | const findLocalCertFromExtensions = async ( 34 | extensions, 35 | commonName, 36 | altNames, 37 | meta, 38 | days 39 | ) => { 40 | const certRenewDate = new Date() 41 | 42 | certRenewDate.setDate(certRenewDate.getDate() + days) 43 | 44 | const localCertSearch = await Promise.all(extensions.map( 45 | async (certLocator) => { 46 | const localCerts = await certLocator.getLocalCerts() 47 | 48 | localCerts.sort((a, b) => { 49 | return (a.notAfter.getTime() > b.notAfter.getTime()) ? -1 : 1 50 | }) 51 | 52 | const matchingCertsIndex = ( 53 | await Promise.all(localCerts.map(async (cert) => { 54 | const { 55 | altNames: certAltNames, 56 | commonName: certCommonName, 57 | notAfter: certNotAfter 58 | } = cert 59 | const certMeta = await getMetaFromCert(cert) 60 | 61 | return ( 62 | certCommonName === commonName && 63 | ( 64 | arrayItemsMatch(certAltNames, altNames) || 65 | (certAltNames.length === 0 && altNames.length === 1) 66 | ) && 67 | certNotAfter.getTime() > certRenewDate.getTime() && 68 | metaItemsMatch(certMeta[certLocator.id], meta[certLocator.id]) 69 | ) 70 | })) 71 | ) 72 | .findIndex((isFound) => isFound) 73 | 74 | return localCerts[matchingCertsIndex] 75 | } 76 | )) 77 | 78 | return localCertSearch.find((localCert) => (localCert !== undefined)) 79 | } 80 | 81 | module.exports = async (payload, { clientName } = {}) => { 82 | const config = await getConfig() 83 | const { domains, days = config.renewalDays } = payload 84 | const meta = await normalizeMeta(payload.meta) 85 | const commonName = domains[0] 86 | const altNames = domains 87 | 88 | if (clientName !== undefined) { 89 | await checkRestrictions(clientName, domains) 90 | } 91 | 92 | debug('Request for certificate', domains, 'meta', meta) 93 | 94 | const extensions = await getExtensionsForDomains(domains) 95 | 96 | let cert = await findLocalCertFromExtensions( 97 | extensions, 98 | commonName, 99 | altNames, 100 | meta, 101 | days 102 | ) 103 | 104 | if (cert === undefined) { 105 | debug('No local certificate found - executing cert generators', domains) 106 | 107 | cert = await generateFirstCertInSequence( 108 | extensions, 109 | commonName, 110 | altNames, 111 | meta 112 | ) 113 | } 114 | 115 | if (cert === undefined) { 116 | throw new FeedbackError('Unable to find or generate requested certificate') 117 | } 118 | 119 | return { bundle: Buffer.from(await cert.getArchive()).toString('base64') } 120 | } 121 | -------------------------------------------------------------------------------- /src/lib/server/actions/getInfo.js: -------------------------------------------------------------------------------- 1 | const packageJson = require('../../../../package.json') 2 | const getExtensions = require('../../getExtensions') 3 | 4 | module.exports = async () => { 5 | const extensions = await getExtensions() 6 | 7 | return { 8 | extensions: Object.values(extensions).map(({ id }) => id), 9 | version: packageJson.version 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/lib/server/actions/getInfo.test.js: -------------------------------------------------------------------------------- 1 | /* global test expect */ 2 | 3 | const getInfo = require('./getInfo') 4 | 5 | test( 6 | 'should output data to match snapshot', 7 | async () => { 8 | await expect(getInfo()).resolves.toMatchSnapshot() 9 | } 10 | ) 11 | -------------------------------------------------------------------------------- /src/lib/server/actions/index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | getCert: require('./getCert'), 3 | getInfo: require('./getInfo') 4 | } 5 | -------------------------------------------------------------------------------- /src/lib/server/createRequestHandler.js: -------------------------------------------------------------------------------- 1 | const FeedbackError = require('../FeedbackError') 2 | const debug = require('debug')('certcache:server') 3 | 4 | module.exports = ({ actions }) => (req, res) => { 5 | const data = [] 6 | 7 | req.on('data', (chunk) => { 8 | data.push(chunk) 9 | }) 10 | 11 | req.on('end', async () => { 12 | const requestBody = data.join('') 13 | let result 14 | 15 | debug('Request received', requestBody) 16 | 17 | const { action, ...payload } = JSON.parse(requestBody) 18 | 19 | try { 20 | if (actions[action] === undefined) { 21 | throw new FeedbackError(`Action '${action}' not found`) 22 | } 23 | 24 | const clientName = req.connection.getPeerCertificate().subject.CN 25 | 26 | result = { 27 | success: true, 28 | data: await actions[action](payload, { clientName }) 29 | } 30 | } catch (error) { 31 | result = { success: false } 32 | 33 | if (error instanceof FeedbackError) { 34 | result.error = error.message 35 | } 36 | 37 | console.error(error) 38 | } 39 | 40 | // socket might be destroyed during long running requests (eg. delays 41 | // waiting for DNS updates) 42 | if (res.socket.destroyed !== true) { 43 | res.writeHead( 44 | result.success ? 200 : 500, 45 | { 'Content-Type': 'application/json' } 46 | ) 47 | res.write(JSON.stringify(result)) 48 | } 49 | res.end() 50 | debug('Response sent') 51 | }) 52 | } 53 | -------------------------------------------------------------------------------- /src/lib/server/serve.js: -------------------------------------------------------------------------------- 1 | const { https } = require('catkeys') 2 | const actions = require('./actions') 3 | const getConfig = require('../getConfig') 4 | const createRequestHandler = require('./createRequestHandler') 5 | 6 | module.exports = async () => { 7 | const config = (await getConfig()) 8 | const server = await https.createServer( 9 | { catKeysDir: config.catKeysDir }, 10 | createRequestHandler({ actions }) 11 | ) 12 | 13 | server.setTimeout(1000 * 60 * config.maxRequestTime) 14 | 15 | const srv = server.listen(config.server.port) 16 | 17 | process.once('SIGTERM', () => { 18 | srv.close() 19 | }) 20 | } 21 | -------------------------------------------------------------------------------- /src/lib/server/serve.test.js: -------------------------------------------------------------------------------- 1 | /* global jest test expect beforeEach afterEach */ 2 | 3 | const serve = require('./serve') 4 | const { https } = require('catkeys') 5 | const actions = require('./actions') 6 | const FeedbackError = require('../FeedbackError') 7 | const { Readable, Writable } = require('stream') 8 | const getConfig = require('../getConfig') 9 | 10 | jest.mock('catkeys') 11 | jest.mock('./actions') 12 | 13 | let action 14 | const payload = { test: 'payload', other: 58008 } 15 | let response 16 | const mockActionReturnValue = { foo: 'bar', test: 123 } 17 | let mockConfig 18 | const listen = jest.fn() 19 | const writeHead = jest.fn() 20 | const mockClientName = 'mockClient' 21 | const close = jest.fn() 22 | const setTimeout = jest.fn() 23 | let mockSocket 24 | 25 | listen.mockReturnValue({ close }) 26 | 27 | console.error = jest.fn() 28 | 29 | https 30 | .createServer 31 | .mockImplementation((options, callback) => { 32 | const requestBody = JSON.stringify({ action, ...payload }) 33 | const _response = [] 34 | const req = new Readable({ read: () => {} }) 35 | const res = new Writable({ write: (chunk, encoding, callback) => { 36 | _response.push(chunk) 37 | callback() 38 | } }) 39 | 40 | req.connection = { 41 | getPeerCertificate: () => ({ subject: { CN: mockClientName } }) 42 | } 43 | 44 | res.writeHead = writeHead 45 | res.socket = mockSocket 46 | 47 | req.push(requestBody) 48 | req.push(null) 49 | 50 | callback(req, res) 51 | 52 | return new Promise((resolve) => { 53 | res.on('finish', () => { 54 | response = _response.join('') 55 | resolve({ listen, setTimeout }) 56 | }) 57 | }) 58 | }) 59 | 60 | actions.testAction = jest.fn() 61 | actions.testAction.mockImplementation(() => { 62 | return Promise.resolve(mockActionReturnValue) 63 | }) 64 | actions.throwingAction = jest.fn() 65 | actions.throwingAction.mockImplementation(() => { 66 | throw new Error('barf!') 67 | }) 68 | 69 | const feedbackErrorMessage = 'Test feedback error message' 70 | 71 | actions.throwingFeedbackErrorAction = jest.fn() 72 | actions.throwingFeedbackErrorAction.mockImplementation(() => { 73 | throw new FeedbackError(feedbackErrorMessage) 74 | }) 75 | 76 | beforeEach(async () => { 77 | action = 'testAction' 78 | mockConfig = await getConfig() 79 | mockSocket = { destroyed: false } 80 | }) 81 | 82 | afterEach(() => { 83 | process.emit('SIGTERM') 84 | }) 85 | 86 | test( 87 | 'should call action submitted in request', 88 | async () => { 89 | await serve() 90 | 91 | expect(actions.testAction).toBeCalledTimes(1) 92 | } 93 | ) 94 | 95 | test( 96 | 'should return data returned by action', 97 | async () => { 98 | await serve() 99 | 100 | expect(JSON.parse(response).data).toEqual(mockActionReturnValue) 101 | } 102 | ) 103 | 104 | test( 105 | 'should return error when action doesn\'t exist', 106 | async () => { 107 | action = 'nonExistantAction' 108 | 109 | await serve() 110 | 111 | expect(JSON.parse(response).success).toBe(false) 112 | } 113 | ) 114 | 115 | test( 116 | 'should return error when error is thrown from action', 117 | async () => { 118 | action = 'throwingAction' 119 | 120 | await serve() 121 | 122 | expect(JSON.parse(response).success).toBe(false) 123 | } 124 | ) 125 | 126 | test( 127 | 'should include error message when throwing a \'FeedbackError\'', 128 | async () => { 129 | action = 'throwingFeedbackErrorAction' 130 | 131 | await serve() 132 | 133 | expect(JSON.parse(response).error).toBe(feedbackErrorMessage) 134 | } 135 | ) 136 | 137 | test( 138 | 'should send a 200 HTTP status code when action completes successfully', 139 | async () => { 140 | await serve() 141 | 142 | expect(writeHead) 143 | .toBeCalledWith(200, { 'Content-Type': 'application/json' }) 144 | } 145 | ) 146 | 147 | test( 148 | 'should send a 500 HTTP status code when action throws an error', 149 | async () => { 150 | action = 'throwingAction' 151 | 152 | await serve() 153 | 154 | expect(writeHead) 155 | .toBeCalledWith(500, { 'Content-Type': 'application/json' }) 156 | } 157 | ) 158 | 159 | test( 160 | 'should start server on port specified in opts', 161 | async () => { 162 | action = 'throwingAction' 163 | 164 | await serve() 165 | 166 | expect(listen).toBeCalledWith(mockConfig.server.port) 167 | } 168 | ) 169 | 170 | test( 171 | 'should stop serving on SIGTERM to exit cleanly', 172 | async () => { 173 | serve() 174 | 175 | await new Promise((resolve) => { setImmediate(resolve) }) 176 | 177 | process.emit('SIGTERM') 178 | 179 | expect(close).toBeCalledTimes(1) 180 | } 181 | ) 182 | 183 | test( 184 | 'should not send response when conncetion has been closed', 185 | async () => { 186 | mockSocket = { destroyed: true } 187 | 188 | await serve() 189 | 190 | expect(writeHead).not.toBeCalled() 191 | } 192 | ) 193 | test( 194 | 'should pass client name to actions', 195 | async () => { 196 | await serve() 197 | 198 | expect(actions.testAction) 199 | .toBeCalledWith(payload, { clientName: mockClientName }) 200 | } 201 | ) 202 | -------------------------------------------------------------------------------- /src/lib/writeBundle.js: -------------------------------------------------------------------------------- 1 | const tar = require('tar-stream') 2 | const zlib = require('zlib') 3 | const { Readable } = require('stream') 4 | const fileExists = require('./helpers/fileExists') 5 | const mkdirRecursive = require('./helpers/mkdirRecursive') 6 | const fs = require('fs') 7 | const path = require('path') 8 | const setAndDemandDirPerms = require('./helpers/setAndDemandDirPerms') 9 | const util = require('util') 10 | const getConfig = require('./getConfig') 11 | 12 | const chmod = util.promisify(fs.chmod) 13 | 14 | module.exports = async (certDir, data) => { 15 | const tarStream = new Readable() 16 | const extract = tar.extract() 17 | const parentDir = path.dirname(certDir) 18 | const { skipFilePerms } = await getConfig() 19 | 20 | tarStream.push(Buffer.from(data, 'base64')) 21 | tarStream.push(null) 22 | 23 | if (await fileExists(certDir) === false) { 24 | await mkdirRecursive(certDir) 25 | } 26 | 27 | if (skipFilePerms !== true) { 28 | await setAndDemandDirPerms(parentDir) 29 | } 30 | 31 | extract.on('entry', ({ name }, stream, next) => { 32 | stream.pipe(fs.createWriteStream(`${certDir}/${name}`)) 33 | stream.on('end', next) 34 | stream.resume() 35 | }) 36 | 37 | return new Promise((resolve, reject) => { 38 | extract.on('error', reject) 39 | extract.on('finish', async () => { 40 | const writeStream = fs 41 | .createWriteStream(path.resolve(certDir, 'fullchain.pem')) 42 | 43 | writeStream.on('finish', () => { 44 | const appendStream = fs.createWriteStream( 45 | path.resolve(certDir, 'fullchain.pem'), 46 | { flags: 'a' } 47 | ) 48 | 49 | fs 50 | .createReadStream(path.resolve(certDir, 'chain.pem')) 51 | .pipe(appendStream) 52 | 53 | appendStream.on('finish', async () => { 54 | if (skipFilePerms !== true) { 55 | await chmod(path.resolve(certDir, 'privkey.pem'), 0o600) 56 | } 57 | 58 | resolve() 59 | }) 60 | }) 61 | fs.createReadStream(path.resolve(certDir, 'cert.pem')).pipe(writeStream) 62 | }) 63 | tarStream.pipe(zlib.createGunzip()).pipe(extract) 64 | }) 65 | } 66 | -------------------------------------------------------------------------------- /src/lib/writeBundle.test.js: -------------------------------------------------------------------------------- 1 | /* global jest test expect beforeEach */ 2 | 3 | const writeBundle = require('./writeBundle') 4 | const tar = require('tar-stream') 5 | const fileExists = require('./helpers/fileExists') 6 | const mkdirRecursive = require('./helpers/mkdirRecursive') 7 | const { Readable, Writable } = require('stream') 8 | const fs = require('fs') 9 | const zlib = require('zlib') 10 | const path = require('path') 11 | const setAndDemandDirPerms = require('./helpers/setAndDemandDirPerms') 12 | const getConfig = require('./getConfig') 13 | 14 | jest.mock('./helpers/fileExists') 15 | jest.mock('./helpers/mkdirRecursive') 16 | jest.mock('fs') 17 | jest.mock('./helpers/setAndDemandDirPerms') 18 | jest.mock('./getConfig') 19 | 20 | const mockFiles = {} 21 | const mockCertDir = '/test/cert/dir' 22 | 23 | setAndDemandDirPerms.mockReturnValue(Promise.resolve()) 24 | 25 | fs.chmod.mockImplementation((path, mode, callback) => { callback(null) }) 26 | 27 | fs.createWriteStream.mockImplementation((path, options = {}) => { 28 | const chunks = [] 29 | const stream = new Writable({ 30 | write: (chunk, encoding, callback) => { 31 | chunks.push(chunk) 32 | callback() 33 | } 34 | }) 35 | 36 | stream.on('finish', () => { 37 | const buffer = (options.flags === 'a') 38 | ? Buffer.concat([mockFiles[path], Buffer.concat(chunks)]) 39 | : Buffer.concat(chunks) 40 | 41 | mockFiles[path] = buffer 42 | }) 43 | 44 | return stream 45 | }) 46 | 47 | fs.createReadStream.mockImplementation((filePath) => { 48 | const stream = new Readable({ 49 | read: () => {} 50 | }) 51 | 52 | stream.push(mockBundle[path.basename(filePath)]) 53 | stream.push(null) 54 | 55 | return stream 56 | }) 57 | 58 | const mockBundle = { 59 | 'cert.pem': Buffer.from('__test cert data__'), 60 | 'chain.pem': Buffer.from('__test ca data__'), 61 | 'privkey.pem': Buffer.from('__test key data__') 62 | } 63 | const mockTarChunks = [] 64 | const pack = tar.pack() 65 | const mockTarStream = new Writable({ 66 | write: (chunk, encoding, callback) => { 67 | mockTarChunks.push(chunk) 68 | callback() 69 | } 70 | }) 71 | 72 | Object.keys(mockBundle).forEach((name) => { 73 | pack.entry({ name }, mockBundle[name]) 74 | }) 75 | 76 | pack.finalize() 77 | 78 | const tarArchivePromise = new Promise((resolve, reject) => { 79 | mockTarStream.on('finish', () => { 80 | resolve(Buffer.concat(mockTarChunks)) 81 | }) 82 | pack.on('error', reject) 83 | mockTarStream.on('error', reject) 84 | pack.pipe(zlib.createGzip()).pipe(mockTarStream) 85 | }) 86 | 87 | beforeEach(() => { 88 | mkdirRecursive.mockReset() 89 | fileExists.mockImplementation(() => Promise.resolve(true)) 90 | fileExists.mockReset() 91 | mkdirRecursive.mockImplementation(() => Promise.resolve()) 92 | }) 93 | 94 | test( 95 | 'should write the archive to the fs', 96 | async () => { 97 | await writeBundle(mockCertDir, await tarArchivePromise) 98 | 99 | expect(mockFiles).toEqual( 100 | Object.keys(mockBundle).reduce( 101 | (acc, key) => ({ 102 | ...acc, 103 | [`${mockCertDir}/${key}`]: mockBundle[key] 104 | }), 105 | { 106 | [`${mockCertDir}/fullchain.pem`]: Buffer.concat([ 107 | mockBundle['cert.pem'], 108 | mockBundle['chain.pem'] 109 | ]) 110 | } 111 | ) 112 | ) 113 | } 114 | ) 115 | 116 | test( 117 | 'create direrctory if it doesn\'t exist', 118 | async () => { 119 | const mockCertPath = '/path/to/cert' 120 | 121 | fileExists.mockImplementation(() => Promise.resolve(false)) 122 | 123 | await writeBundle(mockCertPath, await tarArchivePromise) 124 | 125 | expect(mkdirRecursive).toBeCalledWith(mockCertPath) 126 | } 127 | ) 128 | 129 | test( 130 | 'should set file permissions', 131 | async () => { 132 | const mockCertPath = '/path/to/cert' 133 | 134 | fileExists.mockImplementation(() => Promise.resolve(false)) 135 | 136 | await writeBundle(mockCertPath, await tarArchivePromise) 137 | 138 | expect(fs.chmod).toBeCalledWith( 139 | path.resolve(mockCertPath, 'privkey.pem'), 140 | 0o600, 141 | expect.any(Function) 142 | ) 143 | } 144 | ) 145 | 146 | test( 147 | 'should test file permissions', 148 | async () => { 149 | const mockCertPath = '/path/to/cert' 150 | 151 | await writeBundle(mockCertPath, await tarArchivePromise) 152 | 153 | expect(setAndDemandDirPerms).toBeCalledTimes(1) 154 | } 155 | ) 156 | 157 | test( 158 | 'should skip file permissions when required', 159 | async () => { 160 | const mockCertPath = '/path/to/cert' 161 | 162 | getConfig.mockReturnValueOnce({ 163 | ...(await getConfig()), 164 | skipFilePerms: true 165 | }) 166 | 167 | await writeBundle(mockCertPath, await tarArchivePromise) 168 | 169 | expect(setAndDemandDirPerms).not.toBeCalled() 170 | } 171 | ) 172 | --------------------------------------------------------------------------------