├── .dockerignore ├── .env.example ├── .env.test ├── .gitattributes ├── .github ├── FUNDING.yml ├── dependabot.yaml └── workflows │ ├── build-docker.yaml │ ├── lint.yaml │ └── test.yml ├── .gitignore ├── .vscode └── launch.json ├── .yamllint ├── CHANGES.md ├── Dockerfile ├── HOSTING.md ├── LICENSE ├── README.md ├── app.js ├── index.js ├── jsconfig.json ├── package-lock.json ├── package.json ├── public └── .gitkeep ├── src ├── certnode │ ├── LICENSE │ ├── README.md │ └── lib │ │ ├── client.js │ │ ├── common.js │ │ ├── index.js │ │ └── request.js ├── client.js ├── db.js ├── sni.js ├── tools │ └── migrate.js └── util.js └── test ├── certs ├── README.md ├── localhost │ ├── README.md │ ├── cert.pem │ └── key.pem ├── pebble.minica.key.pem └── pebble.minica.pem ├── go.mod ├── go.sum ├── main.go ├── names.json ├── pebble-config.json └── unit.test.js /.dockerignore: -------------------------------------------------------------------------------- 1 | # General 2 | **/.DS_Store 3 | **/*.md 4 | **/LICENSE 5 | 6 | # Git 7 | **/.git 8 | **/.github 9 | **/.gitattributes 10 | **/.gitignore 11 | 12 | # Docker 13 | **/.dockerignore 14 | **/Dockerfile 15 | 16 | # Node 17 | **/node_modules/ 18 | **/dist 19 | **/npm-debug.log 20 | 21 | # Env 22 | .env 23 | .env.* 24 | !.env.example 25 | 26 | # Generated certs 27 | .certs/ 28 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | HTTP_PORT=80 2 | HTTPS_PORT=443 3 | WHITELIST_HOSTS= 4 | BLACKLIST_HOSTS="bad.example,evil.example" 5 | BLACKLIST_REDIRECT="https://forwarddomain.net/blacklisted" 6 | HOME_DOMAIN=r.forwarddomain.net 7 | -------------------------------------------------------------------------------- /.env.test: -------------------------------------------------------------------------------- 1 | HTTP_PORT=8880 2 | HTTPS_PORT=8843 3 | WHITELIST_HOSTS= 4 | BLACKLIST_HOSTS="bad.example,evil.example" 5 | BLACKLIST_REDIRECT="https://forwarddomain.net/blacklisted" 6 | NODE_ENV=test 7 | # Wait for https://github.com/nodejs/node/pull/51497 8 | # NODE_EXTRA_CA_CERTS=test/certs/pebble.minica.pem 9 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | go.mod eol=lf text=auto 4 | go.sum eol=lf text=auto 5 | package.json eol=lf text=auto 6 | *.lockb binary diff=lockb 7 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | --- 2 | github: willnode 3 | -------------------------------------------------------------------------------- /.github/dependabot.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | version: 2 3 | updates: 4 | - package-ecosystem: github-actions 5 | directory: / 6 | schedule: 7 | interval: weekly 8 | - package-ecosystem: docker 9 | directory: / 10 | schedule: 11 | interval: daily 12 | open-pull-requests-limit: 0 13 | -------------------------------------------------------------------------------- /.github/workflows/build-docker.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Build & Publish Docker 3 | 4 | on: # yamllint disable-line rule:truthy 5 | schedule: 6 | - cron: "0 0 1,15 * *" # Every 2 weeks 7 | push: 8 | branches: 9 | - main 10 | tags: 11 | - v* 12 | workflow_dispatch: 13 | 14 | env: 15 | REGISTRY: ghcr.io 16 | IMAGE_NAME: ${{ github.repository }} 17 | 18 | jobs: 19 | build-and-publish: 20 | name: Build & Publish Docker 21 | runs-on: ubuntu-latest 22 | steps: 23 | - name: Checkout 24 | uses: actions/checkout@v4 25 | - name: Set up QEMU 26 | uses: docker/setup-qemu-action@v3 27 | - name: Set up Docker Buildx 28 | uses: docker/setup-buildx-action@v3 29 | - name: Login to Docker GitHub 30 | uses: docker/login-action@v3 31 | with: 32 | registry: ${{ env.REGISTRY }} 33 | username: ${{ github.actor }} 34 | password: ${{ secrets.GITHUB_TOKEN }} 35 | - name: Extract metadata for the Docker image 36 | id: meta 37 | uses: docker/metadata-action@v5 38 | with: 39 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 40 | tags: | 41 | # set latest tag for default branch 42 | type=semver,pattern={{version}} 43 | type=raw,value=latest,enable={{is_default_branch}} 44 | - name: Build and push 45 | uses: docker/build-push-action@v6 46 | with: 47 | context: . 48 | platforms: linux/amd64,linux/arm64 49 | push: true 50 | tags: ${{ steps.meta.outputs.tags }} 51 | labels: ${{ steps.meta.outputs.labels }} 52 | cache-from: type=gha 53 | cache-to: type=gha,mode=max 54 | -------------------------------------------------------------------------------- /.github/workflows/lint.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Lint 3 | 4 | on: # yamllint disable-line rule:truthy 5 | pull_request: 6 | types: [opened, synchronize, reopened, ready_for_review] 7 | branches: 8 | - main 9 | push: 10 | branches: 11 | - main 12 | 13 | jobs: 14 | yaml-lint: 15 | name: Lint YAML 16 | runs-on: ubuntu-latest 17 | steps: 18 | - name: Checkout 19 | uses: actions/checkout@v4 20 | - name: Run yamllint 21 | run: yamllint . 22 | 23 | docker-lint: 24 | name: Lint Dockerfile 25 | runs-on: ubuntu-latest 26 | steps: 27 | - name: Checkout 28 | uses: actions/checkout@v4 29 | - name: Run hadolint 30 | uses: hadolint/hadolint-action@v3.1.0 31 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Test 3 | 4 | on: # yamllint disable-line rule:truthy 5 | pull_request: 6 | types: [opened, synchronize, reopened, ready_for_review] 7 | branches: 8 | - main 9 | push: 10 | branches: 11 | - main 12 | 13 | jobs: 14 | test: 15 | name: Test 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: actions/checkout@v4 19 | - name: Set up Node 20 | uses: actions/setup-node@v4 21 | with: 22 | node-version: 20 23 | - name: Set up Bun 24 | uses: oven-sh/setup-bun@v2 25 | with: 26 | bun-version: '1.1.2' 27 | - name: Set up Go 28 | uses: actions/setup-go@v5 29 | with: 30 | go-version: '1.22.1' 31 | - name: Install pebble 32 | run: go install github.com/letsencrypt/pebble/v2/cmd/pebble@v2.5.1 33 | - name: Install dnsserver 34 | run: go install github.com/dlorch/dnsserver@latest 35 | - name: Install deps 36 | run: npm install 37 | - name: Unit Test 38 | run: bun test 39 | - name: Integrated Test 40 | run: env PATH=${PATH}:`go env GOPATH`/bin npm test 41 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (https://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # TypeScript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | # parcel-bundler cache (https://parceljs.org/) 61 | .cache 62 | 63 | # next.js build output 64 | .next 65 | 66 | # nuxt.js build output 67 | .nuxt 68 | 69 | # vuepress build output 70 | .vuepress/dist 71 | 72 | # Serverless directories 73 | .serverless 74 | 75 | # FuseBox cache 76 | .fusebox/ 77 | 78 | # Generated certs 79 | .certs/ 80 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "node", 9 | "request": "launch", 10 | "name": "Launch Program", 11 | "skipFiles": [ 12 | "/**" 13 | ], 14 | "program": "${workspaceFolder}\\app.js" 15 | } 16 | ] 17 | } -------------------------------------------------------------------------------- /.yamllint: -------------------------------------------------------------------------------- 1 | --- 2 | ignore: | 3 | node_modules/ 4 | .certs/ 5 | rules: 6 | braces: 7 | level: error 8 | min-spaces-inside: 0 9 | max-spaces-inside: 1 10 | min-spaces-inside-empty: -1 11 | max-spaces-inside-empty: -1 12 | brackets: 13 | level: error 14 | min-spaces-inside: 0 15 | max-spaces-inside: 0 16 | min-spaces-inside-empty: -1 17 | max-spaces-inside-empty: -1 18 | colons: 19 | level: error 20 | max-spaces-before: 0 21 | max-spaces-after: 1 22 | commas: 23 | level: error 24 | max-spaces-before: 0 25 | min-spaces-after: 1 26 | max-spaces-after: 1 27 | comments: 28 | level: error 29 | require-starting-space: true 30 | min-spaces-from-content: 2 31 | comments-indentation: 32 | level: error 33 | document-end: 34 | level: error 35 | present: false 36 | document-start: 37 | level: error 38 | present: true 39 | empty-lines: 40 | level: error 41 | max: 1 42 | max-start: 0 43 | max-end: 1 44 | hyphens: 45 | level: error 46 | max-spaces-after: 1 47 | indentation: 48 | level: error 49 | spaces: 2 50 | indent-sequences: true 51 | check-multi-line-strings: false 52 | key-duplicates: 53 | level: error 54 | line-length: 55 | level: warning 56 | max: 100 57 | allow-non-breakable-words: true 58 | allow-non-breakable-inline-mappings: true 59 | new-line-at-end-of-file: 60 | level: error 61 | new-lines: 62 | level: error 63 | type: unix 64 | trailing-spaces: 65 | level: error 66 | truthy: 67 | level: error 68 | -------------------------------------------------------------------------------- /CHANGES.md: -------------------------------------------------------------------------------- 1 | # CHANGES 2 | 3 | ## v3.0.1-beta (2024-04-22) 4 | 5 | > We're preparing for many breaking changes, stay tuned! 6 | 7 | + Database backend upgrade: 8 | + We're now using SQLite `v3` instead of simple file systems `v2` 9 | + Upgrade from `v2` to `v3` is automatic, certs database will be migrated if possible 10 | + The SQLite file is saved to `.certs/db.sqlite`, users is advised to remove `v2` files if migration has been done without problems 11 | + Stat endpoint: 12 | + We provide `/stat` endpoint with `HOME_DOMAIN` env set instead of old separate domain 13 | + Should be much faster to execute since we count rows with SQLite 14 | + The domain count is reduced since SQLite only captures domains with correct config 15 | + While old `v2` also count domains with failed attempts 16 | + This reduces our stat from ~186000 domains to ~46700 domains! 17 | + Depedencies change: 18 | + Upgrade `jose` from v3 to v5 19 | + Removed `pem` in favor of `rsa-csr` to avoid OpenSSL binaries 20 | + Added `better-sqlite3` for new SQLite DB backend 21 | + Added Domain name and CAA Test conforming Let's Encrypt requirements 22 | + CAA and TXT records should be correctly handled when using CNAME 23 | + Fix to handle multiple CAA records. Contributed by [@djbe](https://github.com/willnode/forward-domain/pull/13) 24 | + Test and code quality improvements 25 | + More complete JSDoc annotations (typescript is overkill for Node) 26 | + Added Unit tests with `bun test` to test utility logics 27 | + Integrated tests: Use extra test certs instead of ignoring insecure ones 28 | + Added CI for Docker image build. Contributed by [@djbe](https://github.com/willnode/forward-domain/pull/17) 29 | + Added CI for Linting and Dependabot. Contributed by [@djbe](https://github.com/willnode/forward-domain/pull/18) 30 | 31 | 32 | ## v3.0.0-beta (2024-04-09) 33 | 34 | + Add integration tests with [Pebble](https://github.com/letsencrypt/pebble) 35 | + Changed few things to make future Let's Encrypt Happy: 36 | + Added [`SubjectAltName`](https://github.com/letsencrypt/pebble/issues/233) in addition to `CommonName` when doing CSR 37 | + Added support for [asynchronous Let's Encrypt order flow](https://community.letsencrypt.org/t/193522) 38 | + Add a domain whitelisting mechanism using `WHITELIST_HOSTS` envar 39 | + Removed `pm2` and `dotenv` to make CI's installation faster 40 | + Please install `pm2` manually if you need that (`npx pm2`) 41 | + Please load `.env` manually with `--env-file=.env` (Node >= 20) 42 | + `index.js` now exports `plainServer` and `secureServer` 43 | 44 | ## v2.6 (2024-02-06) 45 | 46 | + Throw error when directly accessing by IP address 47 | 48 | ## v2.5 (2023-04-24) 49 | 50 | + Add `http-status` TXT record option to set HTTP status code. Contributed by [@dzegarra](https://github.com/willnode/forward-domain/pull/4) 51 | + Improve lock mechanism when a website is verificating certs. 52 | 53 | ## v2.4 (2023-04-21) 54 | 55 | + Fix global service lock when a website is verificating certs. 56 | + Update code deps, refactor imports to ESM. 57 | 58 | ## v2.3 (2022-08-16) 59 | 60 | + Add stat API `s.forwarddomain.net`, separate node script. 61 | 62 | ## v2.2 (2022-06-16) 63 | 64 | + Moving all parameters to `.env` file 65 | + Add a domain blacklist mechanism (we received a report that this service is used for phising activity, for the first time) 66 | + Configure using `BLACKLIST_HOSTS` 67 | 68 | ## v2.1 (2022-01-03) 69 | 70 | + Added `AAAA` record in `r.forwarddomain.net` for IPv6 support. (see [#2](https://github.com/willnode/forward-domain/issues/2#issuecomment-1003831835) for apex domains setup) 71 | 72 | ## v2.0 (2021-09-21) 73 | 74 | + Dropped `forward-domain-cert-maintainer=` record (as of [LE explanation](https://letsencrypt.org/docs/integration-guide/#who-is-the-subscriber) the provider is the bearer). 75 | + The software is now keeping LE's account keypair instead of generating new one every restart. 76 | + Changed IPv4 from `206.189.61.89` to `167.172.5.31` (we use DO's floating IP address now) 77 | + Changed TXT location to subdomain `_` (because TXT can't be put together with CNAME) 78 | + Dropped IPv6 record. 79 | 80 | ## v1.0 (2021-08-23) 81 | 82 | + First release 83 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | ARG NODE_VERSION=lts 2 | 3 | # 4 | # --- Build Stage --- 5 | # 6 | 7 | FROM node:${NODE_VERSION}-alpine as build 8 | 9 | # Install dependencies 10 | WORKDIR /app 11 | COPY package.json package-lock.json ./ 12 | RUN npm install --frozen-lockfile 13 | 14 | # Copy codebase 15 | COPY . . 16 | 17 | # 18 | # --- Base Stage --- 19 | # 20 | 21 | FROM node:${NODE_VERSION}-alpine as base 22 | 23 | USER nobody 24 | 25 | # Copy codebase 26 | WORKDIR /app 27 | COPY --from=build --chown=nobody /app/package.json /app/index.js ./ 28 | COPY --from=build --chown=nobody /app/node_modules node_modules 29 | COPY --from=build --chown=nobody /app/src src 30 | 31 | # Location of generated SSL certificates 32 | VOLUME /app/.certs 33 | 34 | # 35 | # --- App Stage --- 36 | # 37 | 38 | FROM base as app 39 | 40 | ENV NODE_ENV=production 41 | COPY --from=build --chown=nobody /app/app.js . 42 | 43 | # A comma-separated list of root domains to whitelist 44 | ENV WHITELIST_HOSTS= 45 | # A comma-separated list of root domains to blacklist 46 | ENV BLACKLIST_HOSTS= 47 | # The URL to redirect to when a blacklisted host is accessed 48 | ENV BLACKLIST_REDIRECT= 49 | # The host to enable `/stat` endpoint 50 | ENV HOME_DOMAIN= 51 | 52 | ENV HTTP_PORT=8080 HTTPS_PORT=8443 53 | EXPOSE 8080 8443 54 | 55 | ENTRYPOINT ["node"] 56 | CMD ["app.js"] 57 | -------------------------------------------------------------------------------- /HOSTING.md: -------------------------------------------------------------------------------- 1 | # Self-Hosting Guide 2 | 3 | This guide will walk you through the process of setting up your own instance of ForwardDomain. This is not a guide for setting up a development environment, but rather a guide for setting up a production instance. 4 | 5 | ## Prerequisites 6 | 7 | - `node` LTS node (20.x or higher) 8 | - `go` (>= 1.22) and `bun` (>= 1.1) for running tests 9 | - A server with public IP address installed 10 | 11 | ## Installation 12 | 13 | 1. Clone the repository: `git clone https://github.com/willnode/forward-domain.git` 14 | 2. Install dependencies: `npm install` 15 | 3. Copy `.env.example` to `.env` and fill in the values 16 | 4. Run the app: `npm start` 17 | 18 | ## Configuration 19 | 20 | ### Environment Variables 21 | 22 | | Variable | Description | 23 | | --- | --- | 24 | `HTTP_PORT` | The port to listen for HTTP requests 25 | `HTTPS_PORT` | The port to listen for HTTPS requests 26 | `WHITELIST_HOSTS` | A comma-separated list of root domains to whitelist 27 | `BLACKLIST_HOSTS` | A comma-separated list of root domains to blacklist 28 | `BLACKLIST_REDIRECT` | The URL to redirect to when a blacklisted host is accessed 29 | `HOME_DOMAIN` | The host to enable `/stat` endpoint 30 | `USE_LOCAL_DNS` | Default is `false`, so the Google DNS is used. Set it to `true` if you want to use the DNS resolver of your own host 31 | `CACHE_EXPIRY_SECONDS` | Option to override the default cache TTL of 86400 seconds (1 day) 32 | `DEBUG_LEVEL` | Default level is 0 (disabled) and can be set up to level 3 for maximum information 33 | 34 | If `WHITELIST_HOSTS` is set, `BLACKLIST_HOSTS` is ignored. Both is mutually exclusive. 35 | 36 | If `BLACKLIST_REDIRECT` empty or unset, it will not attempt to generate certificates on HTTPS (resulting "alert handshake failure" closing connection immediately) or return 403 on HTTP. It's recommended to leave this blank if `WHITELIST_HOSTS` is set. 37 | 38 | ### Startup Files 39 | 40 | + `app.js` This is the startup file for production, listening on both `HTTP_PORT` and `HTTPS_PORT`. 41 | + `index.js` This is for development or testing only, listening to only `HTTP_PORT` on main or exporting the module. 42 | + `stat.js` A simple background service [for providing stat](https://s.forwarddomain.net) listening to `STAT_PORT`. 43 | 44 | ### SSL Certificates 45 | 46 | SSL certificates is saved in `./.certs` directory. No additional configuration is needed. 47 | 48 | ## Running the App 49 | 50 | `sudo npm start` is recommended to run the app. This is because the app needs to listen to port 80 and 443 directly, which requires root access. 51 | 52 | If you want to run the app without root access, or wanted to filter some domains for other services, you have to use NGINX with stream plugin. 53 | 54 | ## NGINX + Stream Plugin 55 | 56 | You cannot run this server via regular NGINX's `server` directive because that's mean you won't get benefited from automatic HTTPS cert installation and only-DNS-needed setup approach. 57 | 58 | [NGINX Stream plugin](http://nginx.org/en/docs/stream/ngx_stream_core_module.html) is used to filter some domain while still be able forwards HTTPS connection directly. It has to be that way since NGINX doesn't handle HTTPS certificates. 59 | 60 | This configuration below, setups the following: 61 | + Port `80` is listened by `http` block, with default site forwards connection to port `5080`. 62 | + Port `443` is listened by `stream` block, with default stream forwards connection to port `5443`. 63 | + All normal HTTPS connection in `http` block listen to `6443`, to be cached by some domains in `stream` block. 64 | + Port `5080` and `5443` for http, https is set for `forward-domain` service listened to. 65 | 66 | 67 | ```nginx 68 | user nginx; 69 | worker_processes auto; 70 | error_log /var/log/nginx/error.log; 71 | pid /run/nginx.pid; 72 | 73 | include /usr/share/nginx/modules/*.conf; 74 | 75 | events { 76 | worker_connections 1024; 77 | } 78 | stream { 79 | upstream main { 80 | server 167.172.5.31:6443; 81 | } 82 | upstream forwarder { 83 | server 167.172.5.31:5443; 84 | } 85 | 86 | map $ssl_preread_server_name $upstream { 87 | forwarddomain.net main; 88 | default forwarder; 89 | } 90 | 91 | server { 92 | listen 167.172.5.31:443; 93 | listen [2400:6180:0:d0::e08:a001]:443; 94 | resolver 1.1.1.1; 95 | proxy_pass $upstream; 96 | ssl_preread on; 97 | } 98 | } 99 | http { 100 | server { 101 | server_name _ default_server; 102 | listen 167.172.5.31; 103 | listen [2400:6180:0:d0::e08:a001]; 104 | location / { 105 | proxy_pass http://127.0.0.1:5080; 106 | proxy_set_header Host $host; 107 | } 108 | } 109 | 110 | server { 111 | server_name forwarddomain.net; 112 | listen 167.172.5.31; 113 | listen [2400:6180:0:d0::e08:a001]; 114 | location / { 115 | proxy_pass http://127.0.0.1:5900; 116 | proxy_set_header Host $host; 117 | } 118 | listen 167.172.5.31:6443 ssl; 119 | listen [2400:6180:0:d0::e08:a001]:6443 ssl; 120 | ssl_certificate /home/web/ssl.combined; 121 | ssl_certificate_key /home/web/ssl.key; 122 | } 123 | } 124 | ``` 125 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Wildan M 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Forward Domain 2 | 3 | [![GitHub Repo stars](https://img.shields.io/github/stars/willnode/forward-domain?style=for-the-badge)](https://github.com/willnode/forward-domain/stargazers) 4 | [![Uptime Robot ratio (30 days)](https://img.shields.io/uptimerobot/ratio/m790428156-91b88afc46cfb86ead3dc56e?style=for-the-badge)](https://stats.uptimerobot.com/AA77Xt9Jx8) 5 | 6 | Banner 7 | 8 | > For hosting guide See [HOSTING.md](HOSTING.md) and [CHANGES.md](CHANGES.md) 9 | 10 | This service forwards domains using HTTP(s) redirects. 11 | 12 | Example scenarios: 13 | 14 | + Forward non-www to www domains or vice versa 15 | + Forward old domains to new domains 16 | 17 | Why using this service? 18 | 19 | + No coding required 20 | + No hosting required 21 | + No registration required 22 | + Completely anonymous 23 | + Completely free 24 | 25 | How does it works? 26 | 27 | + Point your domain to us using CNAME or A/AAAA records 28 | + Tell us where to forward using TXT records 29 | + We handle HTTPS certificates for you 30 | 31 | ## Get Started 32 | 33 | To forward from `www.old.com` to `old.com`, add these records to your DNS: 34 | 35 | ``` 36 | www.old.com IN CNAME r.forwarddomain.net 37 | _.www.old.com IN TXT forward-domain=https://old.com/* 38 | ``` 39 | 40 | Because CNAME can't be used in apex domains, you can use A/AAAA records.
41 | To forward from `old.com` to `new.net`, add these records to your DNS: 42 | 43 | ``` 44 | old.com IN A 167.172.5.31 45 | _.old.com IN TXT forward-domain=https://new.net/* 46 | ``` 47 | 48 | The star `*` at the end tells us that the remaining URL path is also need to be forwarded to the destination URL. 49 | 50 | > If you use Cloudflare or any DNS which supports [CNAME Flattening](https://blog.cloudflare.com/introducing-cname-flattening-rfc-compliant-cnames-at-a-domains-root/), you still can use CNAME records pointing to `r.forwarddomain.net`. It's recommended to use CNAME records rather than A/AAAA records. 51 | 52 | You can choose the type of redirection you want to use by declaring the `http-status` value: 53 | 54 | ``` 55 | www.old.com IN CNAME r.forwarddomain.net 56 | _.www.old.com IN TXT http-status=302;forward-domain=https://old.com/* 57 | ``` 58 | 59 | The HTTP codes available for use include: 60 | 61 | + `301` Permanent redirection (default) 62 | + `302` Temporary redirection (may keeping SEO from indexing new location) 63 | + `307` Temporary redirection while keeping HTTP verb 64 | + `308` Permanent redirection while keeping HTTP verb 65 | 66 | ## FAQ 67 | 68 | ### Is it really free? 69 | 70 | Forwarding domains should be easy to setup.
71 | I use this myself for [domcloud.io](https://domcloud.io).
72 | 73 | ### How can I check redirects will work? 74 | 75 | This service uses Google's [Public DNS Resolver](https://dns.google).
76 | Once first accessed, values will be cached for a day.
77 | For right now there's no way to flush the cache sorry. 78 | 79 | ### Why it loads slowly? 80 | 81 | It only slow at first time because it has to sign HTTPS certificates. 82 | 83 | ### How about IPv6? 84 | 85 | IPv6 record is added in `r.forwarddomain.net` so subdomain redirects will simply work with IPv6. We don't guarantee that its IPv6 address will be persistent though. See [#2](https://github.com/willnode/forward-domain/issues/2#issuecomment-1003831835) for apex domains setup. 86 | 87 | ### What records do we keep? 88 | 89 | We only keep caches of DNS records and SSL certs. This also means we can see how many users and what domains are using our service from the software cache, but that's all. We don't keep log traffic nor keep any user data anywhere on our server. 90 | 91 | ### How can I support this service? 92 | 93 | Star our repo and spread the word, please :) 94 | 95 | Additionally, you can also help us [cover hosting costs](https://github.com/sponsors/willnode). 96 | 97 | ## Credits 98 | 99 | Things in `package.json`. I also borrow code from [zbo14/certnode](https://github.com/zbo14/certnode). 100 | 101 | ## Usual Disclaimer 102 | 103 | ``` 104 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 105 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 106 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 107 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 108 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 109 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 110 | SOFTWARE. 111 | ``` 112 | -------------------------------------------------------------------------------- /app.js: -------------------------------------------------------------------------------- 1 | import { plainServer, secureServer } from "./index.js"; 2 | import { pruneCache as pruneCacheSni } from "./src/sni.js"; 3 | import { pruneCache as pruneCacheClient } from "./src/client.js"; 4 | import { clearConfig } from "./src/util.js"; 5 | import fs from "fs"; 6 | import { watch } from "chokidar"; 7 | import dotenv from "dotenv"; 8 | 9 | // Function to reload the .env variables 10 | function reloadEnv() { 11 | if (fs.existsSync('.env')) { 12 | const envConfig = dotenv.parse(fs.readFileSync('.env')); 13 | for (const k in envConfig) { 14 | process.env[k] = envConfig[k]; 15 | } 16 | console.log('Environment variables reloaded.'); 17 | } else { 18 | console.warn('.env file does not exist.'); 19 | } 20 | } 21 | 22 | // Watch the .env file for changes 23 | watch('.env').on('change', () => { 24 | console.log('.env file changed, reloading...'); 25 | clearConfig(); 26 | pruneCacheClient(); 27 | pruneCacheSni(); 28 | reloadEnv(); 29 | }); 30 | 31 | // Initial load 32 | reloadEnv(); 33 | 34 | 35 | const port80 = parseInt(process.env.HTTP_PORT || "8080"); 36 | const port443 = parseInt(process.env.HTTPS_PORT || "8443"); 37 | console.log("Forward Domain running with env", process.env.NODE_ENV); 38 | plainServer.listen(port80, () => console.log(`HTTP server start at port ${port80}`)); 39 | secureServer.listen(port443, () => console.log(`HTTPS server start at port ${port443}`)); 40 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | import https from "https"; 2 | import http from "http"; 3 | import { listener } from "./src/client.js"; 4 | import { SniPrepare, SniListener, SniDispose } from "./src/sni.js"; 5 | import { isMainProcess } from "./src/util.js"; 6 | 7 | // development endpoint (use ngrok) 8 | const plainServer = http.createServer(listener); 9 | const secureServer = https.createServer({ 10 | SNICallback: SniListener, 11 | }, listener); 12 | 13 | secureServer.on('listening', SniPrepare); 14 | secureServer.on('close', SniDispose) 15 | 16 | if (isMainProcess(import.meta.url)) { 17 | const port = parseInt(process.env.HTTP_PORT || "3000"); 18 | plainServer.listen(port, function () { 19 | console.log(`HTTP server start at port ${port}`); 20 | }); 21 | } 22 | 23 | export { 24 | plainServer, 25 | secureServer 26 | } 27 | 28 | -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "module": "NodeNext", 5 | "allowJs": true, 6 | "checkJs": true, 7 | "strict": true 8 | }, 9 | "exclude": [ 10 | "node_modules", 11 | ".certs" 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "forward-domain", 3 | "version": "1.0.0", 4 | "lockfileVersion": 3, 5 | "requires": true, 6 | "packages": { 7 | "": { 8 | "name": "forward-domain", 9 | "version": "1.0.0", 10 | "license": "MIT", 11 | "dependencies": { 12 | "async-lock": "^1.4.1", 13 | "better-sqlite3": "^11.1.2", 14 | "chokidar": "^3.5.3", 15 | "dotenv": "^16.0.3", 16 | "jose": "^5.6.3", 17 | "lru-cache": "^11.0.0", 18 | "node-forge": "^1.3.1", 19 | "rsa-csr": "^1.0.6", 20 | "validator": "^13.9.0" 21 | }, 22 | "devDependencies": { 23 | "@types/bun": "^1.1.6" 24 | }, 25 | "engines": { 26 | "node": ">=16.0.0" 27 | } 28 | }, 29 | "node_modules/@types/bun": { 30 | "version": "1.1.6", 31 | "resolved": "https://registry.npmjs.org/@types/bun/-/bun-1.1.6.tgz", 32 | "integrity": "sha512-uJgKjTdX0GkWEHZzQzFsJkWp5+43ZS7HC8sZPFnOwnSo1AsNl2q9o2bFeS23disNDqbggEgyFkKCHl/w8iZsMA==", 33 | "dev": true, 34 | "dependencies": { 35 | "bun-types": "1.1.17" 36 | } 37 | }, 38 | "node_modules/@types/node": { 39 | "version": "20.12.14", 40 | "resolved": "https://registry.npmjs.org/@types/node/-/node-20.12.14.tgz", 41 | "integrity": "sha512-scnD59RpYD91xngrQQLGkE+6UrHUPzeKZWhhjBSa3HSkwjbQc38+q3RoIVEwxQGRw3M+j5hpNAM+lgV3cVormg==", 42 | "dev": true, 43 | "dependencies": { 44 | "undici-types": "~5.26.4" 45 | } 46 | }, 47 | "node_modules/@types/ws": { 48 | "version": "8.5.11", 49 | "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.11.tgz", 50 | "integrity": "sha512-4+q7P5h3SpJxaBft0Dzpbr6lmMaqh0Jr2tbhJZ/luAwvD7ohSCniYkwz/pLxuT2h0EOa6QADgJj1Ko+TzRfZ+w==", 51 | "dev": true, 52 | "dependencies": { 53 | "@types/node": "*" 54 | } 55 | }, 56 | "node_modules/anymatch": { 57 | "version": "3.1.3", 58 | "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", 59 | "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", 60 | "license": "ISC", 61 | "dependencies": { 62 | "normalize-path": "^3.0.0", 63 | "picomatch": "^2.0.4" 64 | }, 65 | "engines": { 66 | "node": ">= 8" 67 | } 68 | }, 69 | "node_modules/async-lock": { 70 | "version": "1.4.1", 71 | "license": "MIT" 72 | }, 73 | "node_modules/base64-js": { 74 | "version": "1.5.1", 75 | "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", 76 | "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", 77 | "funding": [ 78 | { 79 | "type": "github", 80 | "url": "https://github.com/sponsors/feross" 81 | }, 82 | { 83 | "type": "patreon", 84 | "url": "https://www.patreon.com/feross" 85 | }, 86 | { 87 | "type": "consulting", 88 | "url": "https://feross.org/support" 89 | } 90 | ] 91 | }, 92 | "node_modules/better-sqlite3": { 93 | "version": "11.1.2", 94 | "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.1.2.tgz", 95 | "integrity": "sha512-gujtFwavWU4MSPT+h9B+4pkvZdyOUkH54zgLdIrMmmmd4ZqiBIrRNBzNzYVFO417xo882uP5HBu4GjOfaSrIQw==", 96 | "hasInstallScript": true, 97 | "dependencies": { 98 | "bindings": "^1.5.0", 99 | "prebuild-install": "^7.1.1" 100 | } 101 | }, 102 | "node_modules/binary-extensions": { 103 | "version": "2.3.0", 104 | "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", 105 | "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", 106 | "license": "MIT", 107 | "engines": { 108 | "node": ">=8" 109 | }, 110 | "funding": { 111 | "url": "https://github.com/sponsors/sindresorhus" 112 | } 113 | }, 114 | "node_modules/bindings": { 115 | "version": "1.5.0", 116 | "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", 117 | "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", 118 | "dependencies": { 119 | "file-uri-to-path": "1.0.0" 120 | } 121 | }, 122 | "node_modules/bl": { 123 | "version": "4.1.0", 124 | "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", 125 | "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", 126 | "dependencies": { 127 | "buffer": "^5.5.0", 128 | "inherits": "^2.0.4", 129 | "readable-stream": "^3.4.0" 130 | } 131 | }, 132 | "node_modules/braces": { 133 | "version": "3.0.3", 134 | "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", 135 | "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", 136 | "license": "MIT", 137 | "dependencies": { 138 | "fill-range": "^7.1.1" 139 | }, 140 | "engines": { 141 | "node": ">=8" 142 | } 143 | }, 144 | "node_modules/buffer": { 145 | "version": "5.7.1", 146 | "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", 147 | "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", 148 | "funding": [ 149 | { 150 | "type": "github", 151 | "url": "https://github.com/sponsors/feross" 152 | }, 153 | { 154 | "type": "patreon", 155 | "url": "https://www.patreon.com/feross" 156 | }, 157 | { 158 | "type": "consulting", 159 | "url": "https://feross.org/support" 160 | } 161 | ], 162 | "dependencies": { 163 | "base64-js": "^1.3.1", 164 | "ieee754": "^1.1.13" 165 | } 166 | }, 167 | "node_modules/bun-types": { 168 | "version": "1.1.17", 169 | "resolved": "https://registry.npmjs.org/bun-types/-/bun-types-1.1.17.tgz", 170 | "integrity": "sha512-Z4+OplcSd/YZq7ZsrfD00DKJeCwuNY96a1IDJyR73+cTBaFIS7SC6LhpY/W3AMEXO9iYq5NJ58WAwnwL1p5vKg==", 171 | "dev": true, 172 | "dependencies": { 173 | "@types/node": "~20.12.8", 174 | "@types/ws": "~8.5.10" 175 | } 176 | }, 177 | "node_modules/chokidar": { 178 | "version": "3.6.0", 179 | "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", 180 | "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", 181 | "license": "MIT", 182 | "dependencies": { 183 | "anymatch": "~3.1.2", 184 | "braces": "~3.0.2", 185 | "glob-parent": "~5.1.2", 186 | "is-binary-path": "~2.1.0", 187 | "is-glob": "~4.0.1", 188 | "normalize-path": "~3.0.0", 189 | "readdirp": "~3.6.0" 190 | }, 191 | "engines": { 192 | "node": ">= 8.10.0" 193 | }, 194 | "funding": { 195 | "url": "https://paulmillr.com/funding/" 196 | }, 197 | "optionalDependencies": { 198 | "fsevents": "~2.3.2" 199 | } 200 | }, 201 | "node_modules/decompress-response": { 202 | "version": "6.0.0", 203 | "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", 204 | "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", 205 | "dependencies": { 206 | "mimic-response": "^3.1.0" 207 | }, 208 | "engines": { 209 | "node": ">=10" 210 | }, 211 | "funding": { 212 | "url": "https://github.com/sponsors/sindresorhus" 213 | } 214 | }, 215 | "node_modules/deep-extend": { 216 | "version": "0.6.0", 217 | "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", 218 | "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", 219 | "engines": { 220 | "node": ">=4.0.0" 221 | } 222 | }, 223 | "node_modules/detect-libc": { 224 | "version": "2.0.3", 225 | "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.3.tgz", 226 | "integrity": "sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==", 227 | "engines": { 228 | "node": ">=8" 229 | } 230 | }, 231 | "node_modules/dotenv": { 232 | "version": "16.4.5", 233 | "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.5.tgz", 234 | "integrity": "sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==", 235 | "license": "BSD-2-Clause", 236 | "engines": { 237 | "node": ">=12" 238 | }, 239 | "funding": { 240 | "url": "https://dotenvx.com" 241 | } 242 | }, 243 | "node_modules/end-of-stream": { 244 | "version": "1.4.4", 245 | "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", 246 | "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", 247 | "dependencies": { 248 | "once": "^1.4.0" 249 | } 250 | }, 251 | "node_modules/expand-template": { 252 | "version": "2.0.3", 253 | "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", 254 | "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", 255 | "engines": { 256 | "node": ">=6" 257 | } 258 | }, 259 | "node_modules/file-uri-to-path": { 260 | "version": "1.0.0", 261 | "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", 262 | "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==" 263 | }, 264 | "node_modules/fill-range": { 265 | "version": "7.1.1", 266 | "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", 267 | "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", 268 | "license": "MIT", 269 | "dependencies": { 270 | "to-regex-range": "^5.0.1" 271 | }, 272 | "engines": { 273 | "node": ">=8" 274 | } 275 | }, 276 | "node_modules/fs-constants": { 277 | "version": "1.0.0", 278 | "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", 279 | "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==" 280 | }, 281 | "node_modules/fsevents": { 282 | "version": "2.3.3", 283 | "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", 284 | "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", 285 | "hasInstallScript": true, 286 | "license": "MIT", 287 | "optional": true, 288 | "os": [ 289 | "darwin" 290 | ], 291 | "engines": { 292 | "node": "^8.16.0 || ^10.6.0 || >=11.0.0" 293 | } 294 | }, 295 | "node_modules/github-from-package": { 296 | "version": "0.0.0", 297 | "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", 298 | "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==" 299 | }, 300 | "node_modules/glob-parent": { 301 | "version": "5.1.2", 302 | "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", 303 | "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", 304 | "license": "ISC", 305 | "dependencies": { 306 | "is-glob": "^4.0.1" 307 | }, 308 | "engines": { 309 | "node": ">= 6" 310 | } 311 | }, 312 | "node_modules/ieee754": { 313 | "version": "1.2.1", 314 | "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", 315 | "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", 316 | "funding": [ 317 | { 318 | "type": "github", 319 | "url": "https://github.com/sponsors/feross" 320 | }, 321 | { 322 | "type": "patreon", 323 | "url": "https://www.patreon.com/feross" 324 | }, 325 | { 326 | "type": "consulting", 327 | "url": "https://feross.org/support" 328 | } 329 | ] 330 | }, 331 | "node_modules/inherits": { 332 | "version": "2.0.4", 333 | "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", 334 | "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" 335 | }, 336 | "node_modules/ini": { 337 | "version": "1.3.8", 338 | "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", 339 | "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==" 340 | }, 341 | "node_modules/is-binary-path": { 342 | "version": "2.1.0", 343 | "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", 344 | "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", 345 | "license": "MIT", 346 | "dependencies": { 347 | "binary-extensions": "^2.0.0" 348 | }, 349 | "engines": { 350 | "node": ">=8" 351 | } 352 | }, 353 | "node_modules/is-extglob": { 354 | "version": "2.1.1", 355 | "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", 356 | "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", 357 | "license": "MIT", 358 | "engines": { 359 | "node": ">=0.10.0" 360 | } 361 | }, 362 | "node_modules/is-glob": { 363 | "version": "4.0.3", 364 | "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", 365 | "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", 366 | "license": "MIT", 367 | "dependencies": { 368 | "is-extglob": "^2.1.1" 369 | }, 370 | "engines": { 371 | "node": ">=0.10.0" 372 | } 373 | }, 374 | "node_modules/is-number": { 375 | "version": "7.0.0", 376 | "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", 377 | "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", 378 | "license": "MIT", 379 | "engines": { 380 | "node": ">=0.12.0" 381 | } 382 | }, 383 | "node_modules/jose": { 384 | "version": "5.6.3", 385 | "resolved": "https://registry.npmjs.org/jose/-/jose-5.6.3.tgz", 386 | "integrity": "sha512-1Jh//hEEwMhNYPDDLwXHa2ePWgWiFNNUadVmguAAw2IJ6sj9mNxV5tGXJNqlMkJAybF6Lgw1mISDxTePP/187g==", 387 | "funding": { 388 | "url": "https://github.com/sponsors/panva" 389 | } 390 | }, 391 | "node_modules/lru-cache": { 392 | "version": "11.0.0", 393 | "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.0.0.tgz", 394 | "integrity": "sha512-Qv32eSV1RSCfhY3fpPE2GNZ8jgM9X7rdAfemLWqTUxwiyIC4jJ6Sy0fZ8H+oLWevO6i4/bizg7c8d8i6bxrzbA==", 395 | "engines": { 396 | "node": "20 || >=22" 397 | } 398 | }, 399 | "node_modules/mimic-response": { 400 | "version": "3.1.0", 401 | "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", 402 | "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", 403 | "engines": { 404 | "node": ">=10" 405 | }, 406 | "funding": { 407 | "url": "https://github.com/sponsors/sindresorhus" 408 | } 409 | }, 410 | "node_modules/minimist": { 411 | "version": "1.2.8", 412 | "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", 413 | "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", 414 | "funding": { 415 | "url": "https://github.com/sponsors/ljharb" 416 | } 417 | }, 418 | "node_modules/mkdirp-classic": { 419 | "version": "0.5.3", 420 | "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", 421 | "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==" 422 | }, 423 | "node_modules/napi-build-utils": { 424 | "version": "1.0.2", 425 | "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-1.0.2.tgz", 426 | "integrity": "sha512-ONmRUqK7zj7DWX0D9ADe03wbwOBZxNAfF20PlGfCWQcD3+/MakShIHrMqx9YwPTfxDdF1zLeL+RGZiR9kGMLdg==" 427 | }, 428 | "node_modules/node-abi": { 429 | "version": "3.60.0", 430 | "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.60.0.tgz", 431 | "integrity": "sha512-zcGgwoXbzw9NczqbGzAWL/ToDYAxv1V8gL1D67ClbdkIfeeDBbY0GelZtC25ayLvVjr2q2cloHeQV1R0QAWqRQ==", 432 | "dependencies": { 433 | "semver": "^7.3.5" 434 | }, 435 | "engines": { 436 | "node": ">=10" 437 | } 438 | }, 439 | "node_modules/node-forge": { 440 | "version": "1.3.1", 441 | "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.1.tgz", 442 | "integrity": "sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA==", 443 | "engines": { 444 | "node": ">= 6.13.0" 445 | } 446 | }, 447 | "node_modules/normalize-path": { 448 | "version": "3.0.0", 449 | "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", 450 | "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", 451 | "license": "MIT", 452 | "engines": { 453 | "node": ">=0.10.0" 454 | } 455 | }, 456 | "node_modules/once": { 457 | "version": "1.4.0", 458 | "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", 459 | "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", 460 | "dependencies": { 461 | "wrappy": "1" 462 | } 463 | }, 464 | "node_modules/picomatch": { 465 | "version": "2.3.1", 466 | "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", 467 | "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", 468 | "license": "MIT", 469 | "engines": { 470 | "node": ">=8.6" 471 | }, 472 | "funding": { 473 | "url": "https://github.com/sponsors/jonschlinkert" 474 | } 475 | }, 476 | "node_modules/prebuild-install": { 477 | "version": "7.1.2", 478 | "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.2.tgz", 479 | "integrity": "sha512-UnNke3IQb6sgarcZIDU3gbMeTp/9SSU1DAIkil7PrqG1vZlBtY5msYccSKSHDqa3hNg436IXK+SNImReuA1wEQ==", 480 | "dependencies": { 481 | "detect-libc": "^2.0.0", 482 | "expand-template": "^2.0.3", 483 | "github-from-package": "0.0.0", 484 | "minimist": "^1.2.3", 485 | "mkdirp-classic": "^0.5.3", 486 | "napi-build-utils": "^1.0.1", 487 | "node-abi": "^3.3.0", 488 | "pump": "^3.0.0", 489 | "rc": "^1.2.7", 490 | "simple-get": "^4.0.0", 491 | "tar-fs": "^2.0.0", 492 | "tunnel-agent": "^0.6.0" 493 | }, 494 | "bin": { 495 | "prebuild-install": "bin.js" 496 | }, 497 | "engines": { 498 | "node": ">=10" 499 | } 500 | }, 501 | "node_modules/pump": { 502 | "version": "3.0.0", 503 | "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", 504 | "integrity": "sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==", 505 | "dependencies": { 506 | "end-of-stream": "^1.1.0", 507 | "once": "^1.3.1" 508 | } 509 | }, 510 | "node_modules/rc": { 511 | "version": "1.2.8", 512 | "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", 513 | "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", 514 | "dependencies": { 515 | "deep-extend": "^0.6.0", 516 | "ini": "~1.3.0", 517 | "minimist": "^1.2.0", 518 | "strip-json-comments": "~2.0.1" 519 | }, 520 | "bin": { 521 | "rc": "cli.js" 522 | } 523 | }, 524 | "node_modules/readable-stream": { 525 | "version": "3.6.2", 526 | "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", 527 | "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", 528 | "dependencies": { 529 | "inherits": "^2.0.3", 530 | "string_decoder": "^1.1.1", 531 | "util-deprecate": "^1.0.1" 532 | }, 533 | "engines": { 534 | "node": ">= 6" 535 | } 536 | }, 537 | "node_modules/readdirp": { 538 | "version": "3.6.0", 539 | "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", 540 | "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", 541 | "license": "MIT", 542 | "dependencies": { 543 | "picomatch": "^2.2.1" 544 | }, 545 | "engines": { 546 | "node": ">=8.10.0" 547 | } 548 | }, 549 | "node_modules/rsa-csr": { 550 | "version": "1.0.6", 551 | "resolved": "https://registry.npmjs.org/rsa-csr/-/rsa-csr-1.0.6.tgz", 552 | "integrity": "sha512-DzBMSLzNX1QLWaccJQRc7ST5AJnWpFnDWgih1iJwpgbMYoTr+yoBZLlGpLFtiasEVYQrxVuubYZzF1sXI1S/ow==", 553 | "hasInstallScript": true, 554 | "bin": { 555 | "rsa-csr": "bin/rsa-csr.js" 556 | } 557 | }, 558 | "node_modules/safe-buffer": { 559 | "version": "5.2.1", 560 | "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", 561 | "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", 562 | "funding": [ 563 | { 564 | "type": "github", 565 | "url": "https://github.com/sponsors/feross" 566 | }, 567 | { 568 | "type": "patreon", 569 | "url": "https://www.patreon.com/feross" 570 | }, 571 | { 572 | "type": "consulting", 573 | "url": "https://feross.org/support" 574 | } 575 | ] 576 | }, 577 | "node_modules/semver": { 578 | "version": "7.6.0", 579 | "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz", 580 | "integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==", 581 | "dependencies": { 582 | "lru-cache": "^6.0.0" 583 | }, 584 | "bin": { 585 | "semver": "bin/semver.js" 586 | }, 587 | "engines": { 588 | "node": ">=10" 589 | } 590 | }, 591 | "node_modules/semver/node_modules/lru-cache": { 592 | "version": "6.0.0", 593 | "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", 594 | "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", 595 | "dependencies": { 596 | "yallist": "^4.0.0" 597 | }, 598 | "engines": { 599 | "node": ">=10" 600 | } 601 | }, 602 | "node_modules/simple-concat": { 603 | "version": "1.0.1", 604 | "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", 605 | "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", 606 | "funding": [ 607 | { 608 | "type": "github", 609 | "url": "https://github.com/sponsors/feross" 610 | }, 611 | { 612 | "type": "patreon", 613 | "url": "https://www.patreon.com/feross" 614 | }, 615 | { 616 | "type": "consulting", 617 | "url": "https://feross.org/support" 618 | } 619 | ] 620 | }, 621 | "node_modules/simple-get": { 622 | "version": "4.0.1", 623 | "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", 624 | "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", 625 | "funding": [ 626 | { 627 | "type": "github", 628 | "url": "https://github.com/sponsors/feross" 629 | }, 630 | { 631 | "type": "patreon", 632 | "url": "https://www.patreon.com/feross" 633 | }, 634 | { 635 | "type": "consulting", 636 | "url": "https://feross.org/support" 637 | } 638 | ], 639 | "dependencies": { 640 | "decompress-response": "^6.0.0", 641 | "once": "^1.3.1", 642 | "simple-concat": "^1.0.0" 643 | } 644 | }, 645 | "node_modules/string_decoder": { 646 | "version": "1.3.0", 647 | "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", 648 | "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", 649 | "dependencies": { 650 | "safe-buffer": "~5.2.0" 651 | } 652 | }, 653 | "node_modules/strip-json-comments": { 654 | "version": "2.0.1", 655 | "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", 656 | "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", 657 | "engines": { 658 | "node": ">=0.10.0" 659 | } 660 | }, 661 | "node_modules/tar-fs": { 662 | "version": "2.1.1", 663 | "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.1.tgz", 664 | "integrity": "sha512-V0r2Y9scmbDRLCNex/+hYzvp/zyYjvFbHPNgVTKfQvVrb6guiE/fxP+XblDNR011utopbkex2nM4dHNV6GDsng==", 665 | "dependencies": { 666 | "chownr": "^1.1.1", 667 | "mkdirp-classic": "^0.5.2", 668 | "pump": "^3.0.0", 669 | "tar-stream": "^2.1.4" 670 | } 671 | }, 672 | "node_modules/tar-fs/node_modules/chownr": { 673 | "version": "1.1.4", 674 | "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", 675 | "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==" 676 | }, 677 | "node_modules/tar-stream": { 678 | "version": "2.2.0", 679 | "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", 680 | "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", 681 | "dependencies": { 682 | "bl": "^4.0.3", 683 | "end-of-stream": "^1.4.1", 684 | "fs-constants": "^1.0.0", 685 | "inherits": "^2.0.3", 686 | "readable-stream": "^3.1.1" 687 | }, 688 | "engines": { 689 | "node": ">=6" 690 | } 691 | }, 692 | "node_modules/to-regex-range": { 693 | "version": "5.0.1", 694 | "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", 695 | "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", 696 | "license": "MIT", 697 | "dependencies": { 698 | "is-number": "^7.0.0" 699 | }, 700 | "engines": { 701 | "node": ">=8.0" 702 | } 703 | }, 704 | "node_modules/tunnel-agent": { 705 | "version": "0.6.0", 706 | "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", 707 | "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", 708 | "dependencies": { 709 | "safe-buffer": "^5.0.1" 710 | }, 711 | "engines": { 712 | "node": "*" 713 | } 714 | }, 715 | "node_modules/undici-types": { 716 | "version": "5.26.5", 717 | "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", 718 | "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", 719 | "dev": true 720 | }, 721 | "node_modules/util-deprecate": { 722 | "version": "1.0.2", 723 | "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", 724 | "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" 725 | }, 726 | "node_modules/validator": { 727 | "version": "13.12.0", 728 | "resolved": "https://registry.npmjs.org/validator/-/validator-13.12.0.tgz", 729 | "integrity": "sha512-c1Q0mCiPlgdTVVVIJIrBuxNicYE+t/7oKeI9MWLj3fh/uq2Pxh/3eeWbVZ4OcGW1TUf53At0njHw5SMdA3tmMg==", 730 | "license": "MIT", 731 | "engines": { 732 | "node": ">= 0.10" 733 | } 734 | }, 735 | "node_modules/wrappy": { 736 | "version": "1.0.2", 737 | "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", 738 | "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" 739 | }, 740 | "node_modules/yallist": { 741 | "version": "4.0.0", 742 | "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", 743 | "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" 744 | } 745 | } 746 | } 747 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "forward-domain", 3 | "version": "1.0.0", 4 | "description": "Public service to forward domain for free", 5 | "main": "app.js", 6 | "exports": { 7 | ".": "./index.js" 8 | }, 9 | "type": "module", 10 | "private": true, 11 | "engines": { 12 | "node": ">=16.0.0" 13 | }, 14 | "scripts": { 15 | "test": "(cd test && go run .)", 16 | "start": "NODE_ENV=production node app.js" 17 | }, 18 | "keywords": [], 19 | "author": "Wildan Mubarok", 20 | "license": "MIT", 21 | "dependencies": { 22 | "async-lock": "^1.4.1", 23 | "better-sqlite3": "^11.1.2", 24 | "chokidar": "^3.5.3", 25 | "dotenv": "^16.0.3", 26 | "jose": "^5.6.3", 27 | "lru-cache": "^11.0.0", 28 | "node-forge": "^1.3.1", 29 | "rsa-csr": "^1.0.6", 30 | "validator": "^13.9.0" 31 | }, 32 | "devDependencies": { 33 | "@types/bun": "^1.1.6" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /public/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/willnode/forward-domain/b642987d31ee140e174752b012dc2105debce6cb/public/.gitkeep -------------------------------------------------------------------------------- /src/certnode/LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2021 Zachary Balder 2 | 3 | Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies. 4 | 5 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 6 | -------------------------------------------------------------------------------- /src/certnode/README.md: -------------------------------------------------------------------------------- 1 | # certnode 2 | 3 | Modified certnode. Please see https://github.com/zbo14/certnode 4 | -------------------------------------------------------------------------------- /src/certnode/lib/client.js: -------------------------------------------------------------------------------- 1 | import rsacsr from "rsa-csr"; 2 | import { exportJWK, generateKeyPair, calculateJwkThumbprint, SignJWT, CompactSign } from "jose"; 3 | import * as common from "./common.js"; 4 | import request from "./request.js"; 5 | /** 6 | * Represents a Let's Encrypt account and 7 | * sends requests to get valid TLS certificates. 8 | */ 9 | class Client { 10 | /** 11 | * @param {String} [directoryUrl] 12 | */ 13 | constructor(directoryUrl = common.DIRECTORY_URL) { 14 | this.accountPrivateJwk = null; 15 | /** @type {import('crypto').KeyObject|null} */ 16 | this.accountPrivateKey = null; 17 | /** @type {import("jose").JWK | undefined} */ 18 | this.accountPublicJwk = undefined; 19 | /** @type {import('crypto').KeyObject|null} */ 20 | this.accountPublicKey = null; 21 | this.directoryUrl = directoryUrl; 22 | this.challengeCallbacks = null; 23 | this.hasDirectory = false; 24 | this.myAccountUrl = ''; 25 | this.newAccountUrl = ''; 26 | this.newNonceUrl = ''; 27 | this.newOrderUrl = ''; 28 | this.replayNonce = ''; 29 | this.thumbprint = ''; 30 | } 31 | /** 32 | * Export account public and private keys to a directory. 33 | * 34 | * @param {String} [passphrase] - optional passphrase to encrypt private key with 35 | * 36 | * @return {{ privateKey: string, publicKey: string }} 37 | */ 38 | exportAccountKeyPair(passphrase) { 39 | if (this.accountPrivateKey == null || this.accountPublicKey == null) { 40 | throw new Error('Account key pair not generated'); 41 | } 42 | return { 43 | // @ts-ignore 44 | accountPrivateKey: common.exportPrivateKey(this.accountPrivateKey, passphrase), 45 | // @ts-ignore 46 | accountPublicKey: common.exportPublicKey(this.accountPublicKey), 47 | } 48 | } 49 | /** 50 | * Generate new account public and private keys. 51 | * 52 | * @return {Promise} 53 | */ 54 | async generateAccountKeyPair() { 55 | const { privateKey, publicKey } = await generateKeyPair(common.ACCOUNT_KEY_ALGORITHM); 56 | // @ts-ignore 57 | this.accountPrivateKey = privateKey; 58 | // @ts-ignore 59 | this.accountPublicKey = publicKey; 60 | await this.initAccountJwks(); 61 | } 62 | /** 63 | * Generate a certificate from Let's Encrypt for your domain. 64 | * 65 | * @param {String} domain - the domain you want a certificate for 66 | * 67 | * @return {Promise<{cert: string, key: string}>} 68 | */ 69 | async generateCertificate(domain) { 70 | await this.directory(); 71 | await this.newNonce(); 72 | if (!this.myAccountUrl) 73 | await this.newAccount(); 74 | const { authzUrls, finalizeUrl } = await this.newOrder(domain); 75 | const { challenge } = await this.authz(authzUrls[0]); 76 | await this.completeChallenge(challenge, domain); 77 | await this.pollAuthz(authzUrls[0]); 78 | const { certificate, privateKeyData } = await this.finalizeOrder(finalizeUrl, domain); 79 | return { 80 | cert: certificate, 81 | key: privateKeyData 82 | }; 83 | } 84 | /** 85 | * Import account from PEM public and private keys. 86 | * @param {string} privateKey 87 | * @param {string} publicKey 88 | * @param {string | undefined} [passphrase] 89 | * @return {Promise} 90 | */ 91 | async importAccountKeyPair(privateKey, publicKey, passphrase) { 92 | this.accountPrivateKey = common.importPrivateKey(privateKey, passphrase); 93 | this.accountPublicKey = common.importPublicKey(publicKey); 94 | await this.initAccountJwks(); 95 | } 96 | /** 97 | * @param {string | URL} authzUrl 98 | */ 99 | async authz(authzUrl) { 100 | const data = await this.sign({ 101 | kid: this.myAccountUrl, 102 | nonce: this.replayNonce, 103 | url: authzUrl 104 | }); 105 | /** @type {import("./request.js").Response<{challenges: {type: string}[], identifier: {value: string}, status: string}>} */ 106 | const res = await request(authzUrl, { 107 | method: 'POST', 108 | headers: { 109 | 'content-type': 'application/jose+json' 110 | }, 111 | data 112 | }); 113 | this.setReplayNonce(res); 114 | if (res.statusCode !== 200) { 115 | throw new Error(`authz() Status Code: ${res.statusCode} Data: ${res.data}`); 116 | } 117 | const { challenges, identifier, ...rest } = res.data; 118 | const challenge = challenges.find(({ type }) => type === 'http-01'); 119 | return { 120 | challenge, 121 | domain: identifier.value, 122 | ...rest 123 | }; 124 | } 125 | /** 126 | * @param {any} challenge 127 | * @param {string} domain 128 | */ 129 | async completeChallenge(challenge, domain) { 130 | await this.readyChallenge(challenge); 131 | await this.receiveServerRequest(challenge, domain); 132 | } 133 | async directory() { 134 | if (this.hasDirectory) 135 | return false; 136 | const res = await request(this.directoryUrl); 137 | if (res.statusCode !== 200) { 138 | throw new Error(`directory() Status Code: ${res.statusCode} Data: ${res.data}`); 139 | } 140 | this.hasDirectory = true; 141 | this.newAccountUrl = res.data.newAccount; 142 | this.newNonceUrl = res.data.newNonce; 143 | this.newOrderUrl = res.data.newOrder; 144 | return true; 145 | } 146 | /** 147 | * @param {string | URL} certificateUrl 148 | */ 149 | async fetchCertificate(certificateUrl) { 150 | const data = await this.sign({ 151 | kid: this.myAccountUrl, 152 | nonce: this.replayNonce, 153 | url: certificateUrl 154 | }); 155 | const res = await request(certificateUrl, { 156 | method: 'POST', 157 | headers: { 158 | accept: 'application/pem-certificate-chain', 159 | 'content-type': 'application/jose+json' 160 | }, 161 | data 162 | }); 163 | this.setReplayNonce(res); 164 | if (res.statusCode !== 200) { 165 | throw new Error(`fetchCertificate() Status Code: ${res.statusCode} Data: ${res.data}`); 166 | } 167 | return res.data; 168 | } 169 | 170 | /** 171 | * @param {any} finalizeUrl 172 | * @param {string} domain 173 | */ 174 | async finalizeOrder(finalizeUrl, domain) { 175 | const { privateKey } = await generateKeyPair(common.CERTIFICATE_KEY_ALGORITHM); 176 | // @ts-ignore 177 | const privateKeyData = common.exportPrivateKey(privateKey); 178 | 179 | let csr = await rsacsr({ 180 | key: await exportJWK(privateKey), 181 | domains: [domain], 182 | }); 183 | 184 | // "The CSR is sent in the base64url-encoded version of the DER format. 185 | // (Note: Because this field uses base64url, and does not include headers, 186 | // it is different from PEM.)" 187 | csr = csr 188 | .split('\n') 189 | .filter(Boolean) 190 | .slice(1, -1) 191 | .join('') 192 | .replace(/\+/g, '-') 193 | .replace(/\//g, '_') 194 | .replace(/=/g, ''); 195 | 196 | const sendFinalizeRequest = async (/** @type {string | URL} */ finalizeUrl, /** @type {import("jose").JWTPayload | undefined} */ payload) => { 197 | const data = await this.sign({ 198 | kid: this.myAccountUrl, 199 | nonce: this.replayNonce, 200 | url: finalizeUrl 201 | }, payload); 202 | 203 | const res = await request(finalizeUrl, { 204 | method: 'POST', 205 | headers: { 206 | 'content-type': 'application/jose+json' 207 | }, 208 | data 209 | }); 210 | this.setReplayNonce(res); 211 | return res; 212 | } 213 | let res = await sendFinalizeRequest(finalizeUrl, { csr }); 214 | // Let's encrypt actually want this to work! 215 | // https://community.letsencrypt.org/t/enabling-asynchronous-order-finalization/193522 216 | while (res.data.status === 'processing') { 217 | let retryUrl = res.headers.location || ''; 218 | let retryTime = parseInt(res.headers["retry-after"] || '1') * 1000 219 | // sleep, retry 220 | await new Promise(resolve => setTimeout(resolve, retryTime)) 221 | res = await sendFinalizeRequest(retryUrl, undefined); 222 | if (res.data.status == "ready") { 223 | res = await sendFinalizeRequest(res.data.finalize, undefined); 224 | break 225 | } 226 | } 227 | if (res.statusCode !== 200) { 228 | throw new Error(`finalizeOrder() Status Code: ${res.statusCode} Data: ${res.data}`); 229 | } 230 | const certificate = await this.fetchCertificate(res.data.certificate); 231 | return { 232 | certificate, 233 | privateKeyData 234 | }; 235 | 236 | } 237 | async initAccountJwks() { 238 | if (this.accountPrivateKey == null || this.accountPublicKey == null) { 239 | return Promise.reject(new Error('Account key pair not generated')); 240 | } 241 | const [publicJwk, accountPrivateJwk] = await Promise.all([ 242 | exportJWK(this.accountPublicKey), 243 | exportJWK(this.accountPrivateKey) 244 | ]); 245 | this.accountPublicJwk = publicJwk; 246 | this.accountPrivateJwk = accountPrivateJwk; 247 | this.thumbprint = await calculateJwkThumbprint(publicJwk); 248 | } 249 | /** 250 | * @param {undefined[]} emails 251 | */ 252 | async newAccount(...emails) { 253 | const data = await this.sign({ 254 | jwk: this.accountPublicJwk, 255 | nonce: this.replayNonce, 256 | url: this.newAccountUrl 257 | }, { 258 | termsOfServiceAgreed: true 259 | }); 260 | const res = await request(this.newAccountUrl, { 261 | method: 'POST', 262 | headers: { 263 | 'content-type': 'application/jose+json' 264 | }, 265 | data 266 | }); 267 | this.setReplayNonce(res); 268 | if (![200, 201].includes(res.statusCode)) { 269 | throw new Error(`newAccount() Status Code: ${res.statusCode} Data: ${res.data}`); 270 | } 271 | this.myAccountUrl = res.headers.location || ""; 272 | return res.statusCode === 201; 273 | } 274 | async newNonce() { 275 | if (this.replayNonce) 276 | return false; 277 | const res = await request(this.newNonceUrl, { 278 | method: 'HEAD' 279 | }); 280 | if (res.statusCode !== 200) { 281 | throw new Error(`newNonce() Status Code: ${res.statusCode} Data: ${res.data}`); 282 | } 283 | this.setReplayNonce(res); 284 | return true; 285 | } 286 | /** 287 | * @param {string[]} domains 288 | */ 289 | async newOrder(...domains) { 290 | const identifiers = domains.map(domain => ({ 291 | type: 'dns', 292 | value: domain 293 | })); 294 | const data = await this.sign({ 295 | kid: this.myAccountUrl, 296 | nonce: this.replayNonce, 297 | url: this.newOrderUrl 298 | }, { 299 | identifiers 300 | }); 301 | const res = await request(this.newOrderUrl, { 302 | method: 'POST', 303 | headers: { 304 | 'content-type': 'application/jose+json' 305 | }, 306 | data 307 | }); 308 | this.setReplayNonce(res); 309 | if (res.statusCode !== 201) { 310 | throw new Error(`newOrder() Status Code: ${res.statusCode} Data: ${res.data}`); 311 | } 312 | const orderUrl = res.headers.location; 313 | const { authorizations: authzUrls, finalize: finalizeUrl } = res.data; 314 | return { 315 | authzUrls, 316 | domains, 317 | finalizeUrl, 318 | orderUrl 319 | }; 320 | } 321 | /** 322 | * @param {any} authzUrl 323 | */ 324 | async pollAuthz(authzUrl) { 325 | for (let i = 0; i < 10; i++) { 326 | const result = await this.authz(authzUrl); 327 | if (result.status === 'pending') { 328 | await new Promise(resolve => setTimeout(resolve, 1e3)); 329 | continue; 330 | } 331 | if (result.status === 'invalid') { 332 | throw new Error('pollAuthz() authorization is invalid: ' + JSON.stringify(result, null, 2)); 333 | } 334 | return result; 335 | } 336 | throw new Error('pollAuthz() timed out'); 337 | } 338 | /** 339 | * @param {{ url: string | URL; }} challenge 340 | */ 341 | async readyChallenge(challenge) { 342 | const data = await this.sign({ 343 | kid: this.myAccountUrl, 344 | nonce: this.replayNonce, 345 | url: challenge.url 346 | }, {}); 347 | const res = await request(challenge.url, { 348 | method: 'POST', 349 | headers: { 350 | 'content-type': 'application/jose+json' 351 | }, 352 | data 353 | }); 354 | this.setReplayNonce(res); 355 | if (res.statusCode !== 200) { 356 | throw new Error(`readyChallenge() Status Code: ${res.statusCode} Data: ${res.data}`); 357 | } 358 | } 359 | /** 360 | * @param {{ token: string; }} challenge 361 | * @param {any} domain 362 | */ 363 | receiveServerRequest(challenge, domain) { 364 | return new Promise((resolve, reject) => { 365 | const time = setTimeout(() => { 366 | reject(new Error('Timed out waiting for server request')); 367 | }, 10e3); 368 | let hasResolved = false; 369 | this.challengeCallbacks = () => { 370 | if (!hasResolved) 371 | setTimeout(resolve, 100); 372 | else 373 | return challenge.token + '.' + this.thumbprint; 374 | hasResolved = true; 375 | clearTimeout(time); 376 | // wanted to clear callbacks here but LE does the call multiple times. 377 | // remember we're in mutex lock so no worries for racing. 378 | return challenge.token + '.' + this.thumbprint; 379 | }; 380 | }); 381 | } 382 | /** 383 | * @param {{ data?: any; headers: any; statusCode?: number; }} res 384 | */ 385 | setReplayNonce(res) { 386 | const replayNonce = (res.headers['replay-nonce'] || '').trim(); 387 | if (!replayNonce) { 388 | throw new Error('No Replay-Nonce header in response'); 389 | } 390 | this.replayNonce = replayNonce; 391 | } 392 | /** 393 | * @param {import("jose").JWSHeaderParameters} header 394 | * @param {import("jose").JWTPayload | undefined} [payload] 395 | */ 396 | async sign(header, payload) { 397 | if (this.accountPrivateKey == null) { 398 | return Promise.reject(new Error('Account key pair not generated')); 399 | } 400 | let data; 401 | if (payload) { 402 | data = await new SignJWT(payload) 403 | // @ts-ignore 404 | .setProtectedHeader({ 405 | alg: common.ACCOUNT_KEY_ALGORITHM, 406 | ...header 407 | }) 408 | .sign(this.accountPrivateKey); 409 | } 410 | else { 411 | // SignJWT constructor only accepts object but RFC8555 requires empty payload 412 | // Workaround: manually pass empty Uint8Array to CompactSign constructor 413 | const sig = new CompactSign(new Uint8Array()); 414 | sig.setProtectedHeader({ 415 | alg: common.ACCOUNT_KEY_ALGORITHM, 416 | ...header 417 | }); 418 | data = await sig.sign(this.accountPrivateKey); 419 | } 420 | const [b64Header, b64Payload, b64Signature] = data.split('.'); 421 | return JSON.stringify({ 422 | protected: b64Header, 423 | payload: b64Payload, 424 | signature: b64Signature 425 | }); 426 | } 427 | } 428 | export default Client; 429 | -------------------------------------------------------------------------------- /src/certnode/lib/common.js: -------------------------------------------------------------------------------- 1 | import crypto from "node:crypto"; 2 | import fs from "node:fs"; 3 | export const ACCOUNT_KEY_ALGORITHM = 'ES256'; 4 | export const CERTIFICATE_KEY_ALGORITHM = 'RS256'; 5 | export const DIRECTORY_URL = { 6 | 'production': 'https://acme-v02.api.letsencrypt.org/directory', 7 | 'development': 'https://acme-staging-v02.api.letsencrypt.org/directory', 8 | 'test': 'https://localhost:14000/dir', 9 | }[(process.env.NODE_ENV || 'development').trim().toLowerCase()] || "" 10 | export const PRIVATE_KEY_CIPHER = 'aes-256-cbc'; 11 | export const PRIVATE_KEY_FORMAT = 'pem'; 12 | export const PRIVATE_KEY_PERMISSIONS = 0o600; 13 | export const PRIVATE_KEY_TYPE = 'pkcs8'; 14 | export const PUBLIC_KEY_FORMAT = 'pem'; 15 | export const PUBLIC_KEY_PERMISSIONS = 0o666; 16 | export const PUBLIC_KEY_TYPE = 'spki'; 17 | /** 18 | * @param {crypto.KeyObject} privateKey 19 | * @param {String} [passphrase] 20 | * @returns {string} 21 | */ 22 | export const exportPrivateKey = (privateKey, passphrase) => { 23 | /** @type {crypto.KeyExportOptions<'pem'>} */ 24 | const privateKeyOpts = { 25 | type: PRIVATE_KEY_TYPE, 26 | format: PRIVATE_KEY_FORMAT 27 | }; 28 | if (passphrase) { 29 | privateKeyOpts.cipher = PRIVATE_KEY_CIPHER; 30 | privateKeyOpts.passphrase = passphrase; 31 | } 32 | // @ts-ignore 33 | return privateKey.export(privateKeyOpts); 34 | }; 35 | /** 36 | * @param {crypto.KeyObject} publicKey 37 | * @returns {string} 38 | */ 39 | export const exportPublicKey = publicKey => { 40 | /** @type {crypto.KeyExportOptions<'pem'>} */ 41 | // @ts-ignore 42 | return publicKey.export({ 43 | type: PUBLIC_KEY_TYPE, 44 | format: PUBLIC_KEY_FORMAT 45 | }); 46 | }; 47 | /** 48 | * @param {String} privateKeyData 49 | * @param {String} [passphrase] 50 | * 51 | * @return {crypto.KeyObject} 52 | */ 53 | export const importPrivateKey = (privateKeyData, passphrase) => { 54 | /** @type {crypto.PrivateKeyInput} */ 55 | const privateKeyOpts = { 56 | key: privateKeyData, 57 | format: PRIVATE_KEY_FORMAT, 58 | type: PRIVATE_KEY_TYPE 59 | }; 60 | if (passphrase) { 61 | privateKeyOpts.passphrase = passphrase; 62 | } 63 | try { 64 | return crypto.createPrivateKey(privateKeyOpts); 65 | } 66 | catch { 67 | throw new Error('Failed to import private key'); 68 | } 69 | }; 70 | /** 71 | * @param {String} publicKeyData 72 | * 73 | * @return {crypto.KeyObject} 74 | */ 75 | export const importPublicKey = publicKeyData => { 76 | try { 77 | return crypto.createPublicKey({ 78 | key: publicKeyData, 79 | format: PUBLIC_KEY_FORMAT, 80 | type: PUBLIC_KEY_TYPE 81 | }); 82 | } 83 | catch { 84 | throw new Error('Failed to import public key'); 85 | } 86 | }; 87 | /** 88 | * @param {String} filename 89 | * @param {crypto.KeyObject|string} key 90 | * @param {String} [passphrase] 91 | * 92 | * @return {Promise} 93 | */ 94 | export const writeKeyToFile = async (filename, key, passphrase) => { 95 | if (typeof key === 'string') { 96 | key = key.includes('PRIVATE KEY') 97 | ? importPrivateKey(key, passphrase) 98 | : importPublicKey(key); 99 | } 100 | else if (!(key instanceof crypto.KeyObject)) { 101 | throw new Error('Expected "key" to be crypto.KeyObject or string'); 102 | } 103 | const isPrivateKey = key.type === 'private'; 104 | const keyData = isPrivateKey 105 | ? exportPrivateKey(key, passphrase) 106 | : exportPublicKey(key); 107 | const mode = isPrivateKey ? PRIVATE_KEY_PERMISSIONS : PUBLIC_KEY_PERMISSIONS; 108 | await fs.promises.writeFile(filename, keyData, { mode }); 109 | }; 110 | 111 | -------------------------------------------------------------------------------- /src/certnode/lib/index.js: -------------------------------------------------------------------------------- 1 | import Client from './client.js'; 2 | export * from './common.js'; 3 | export { Client }; 4 | -------------------------------------------------------------------------------- /src/certnode/lib/request.js: -------------------------------------------------------------------------------- 1 | import http from "node:http"; 2 | import https from "node:https"; 3 | /** 4 | * @template T 5 | * @typedef {{data: T, headers: import('http').IncomingHttpHeaders, statusCode: number}} Response 6 | */ 7 | 8 | /** 9 | * @template T 10 | * @param {string | URL} url 11 | * @param {import('https').RequestOptions & {data?: string}} [options] 12 | * @return {Promise>} 13 | */ 14 | const request = (url, { data = '', ...options } = {}) => { 15 | return new Promise((resolve, reject) => { 16 | try { 17 | url = new URL(url); 18 | } 19 | catch (err) { 20 | return reject(err); 21 | } 22 | (url.protocol == 'https:' ? https : http).request(url, options, res => { 23 | const { statusCode, headers } = res; 24 | /** 25 | * @type {any} 26 | */ 27 | let data = ''; 28 | res 29 | .on('data', chunk => { 30 | data += chunk; 31 | }) 32 | .once('end', () => { 33 | if (headers['content-type']?.includes('application/json')) { 34 | try { 35 | data = JSON.parse(data); 36 | } 37 | catch (err) { 38 | reject(err); 39 | return; 40 | } 41 | } 42 | resolve({ data, headers, statusCode: statusCode || 0 }); 43 | }) 44 | .once('error', reject); 45 | }) 46 | .once('error', reject) 47 | .end(data); 48 | setTimeout(() => { 49 | const method = options.method || 'GET'; 50 | reject(new Error(`${method} request to "${url}" timed out`)); 51 | }, 10e3); 52 | }); 53 | }; 54 | export default request; 55 | -------------------------------------------------------------------------------- /src/client.js: -------------------------------------------------------------------------------- 1 | import { LRUCache } from "lru-cache"; 2 | import { client, getStat } from "./sni.js"; 3 | import querystring from 'querystring'; 4 | import validator from 'validator'; 5 | import { 6 | findTxtRecord, 7 | isHostBlacklisted, 8 | combineURLs, 9 | isIpAddress, 10 | blacklistRedirectUrl, 11 | isExceedLabelLimit, 12 | validateCAARecords, 13 | isExceedHostLimit, 14 | isHttpCodeAllowed, 15 | debugOutput, 16 | getExpiryDate 17 | } from "./util.js"; 18 | 19 | const MAX_DATA_SIZE = 10 * 1024; // 10 KB 20 | 21 | /** 22 | * @typedef {Object} Cache 23 | * @property {string} url 24 | * @property {boolean} expand 25 | * @property {boolean} blacklisted 26 | * @property {number} expire 27 | * @property {number} httpStatus 28 | */ 29 | /** 30 | * @type {LRUCache} 31 | */ 32 | let resolveCache = new LRUCache({ max: 10000 }); 33 | 34 | function pruneCache() { 35 | resolveCache = new LRUCache({ max: 10000 }); 36 | } 37 | 38 | /** 39 | * @param {string} host 40 | * @returns {Promise} 41 | */ 42 | async function buildCache(host) { 43 | if (isIpAddress(host)) { 44 | // https://community.letsencrypt.org/t/90635/2 45 | throw new Error('unable to serve with direct IP address'); 46 | } 47 | if (isExceedHostLimit(host)) { 48 | // https://stackoverflow.com/q/39035571/3908409 49 | throw new Error('Host name is too long (Must no more than 64 char)'); 50 | } 51 | if (isExceedLabelLimit(host)) { 52 | // https://community.letsencrypt.org/t/138688/5 53 | throw new Error('Host parts is too long (Must less than 10 dots)'); 54 | } 55 | let caaRecords = await validateCAARecords(host); 56 | if (caaRecords) { 57 | // https://community.letsencrypt.org/t/199119/2 58 | throw new Error(`CAA record is not "letsencrypt.org". Records found: ${caaRecords}.`); 59 | } 60 | let expand = false; 61 | let recordData = await findTxtRecord(host); 62 | if (!recordData) { 63 | throw new Error(`The TXT record data for "_.${host}" or "fwd.${host}" is missing`); 64 | } 65 | let { url, httpStatus = '302' } = recordData; 66 | if (url.indexOf('http://') !== 0 && url.indexOf('https://') !== 0) { 67 | throw new Error(`The TXT record data for "_.${host}" or "fwd.${host}" is not an absolute URL`); 68 | } 69 | if (url.endsWith('*')) { 70 | url = url.slice(0, -1); 71 | expand = true; 72 | } 73 | if (!isHttpCodeAllowed(httpStatus)) { 74 | throw new Error(`The record "${url}" wants to use the http status code ${httpStatus} which is not allowed (only 301, 302, 307 and 308)`); 75 | } 76 | return { 77 | url, 78 | expand, 79 | blacklisted: isHostBlacklisted(host), 80 | expire: getExpiryDate(), 81 | httpStatus: parseInt(httpStatus), 82 | }; 83 | } 84 | 85 | const acme_prefix = '/.well-known/acme-challenge/'; 86 | 87 | /** 88 | * @type {import('http').RequestListener} 89 | */ 90 | const listener = async function (req, res) { 91 | try { 92 | const url = req.url || ''; 93 | if (url.startsWith(acme_prefix)) { 94 | if (client.challengeCallbacks) { 95 | res.writeHead(200, { 96 | // This is important :) 97 | 'content-type': 'application/octet-stream' 98 | }); 99 | res.write(client.challengeCallbacks()); 100 | } 101 | else { 102 | res.writeHead(404); 103 | } 104 | return; 105 | } 106 | const host = (req.headers.host || '').toLowerCase().replace(/:\d+$/, ''); 107 | if (!host) { 108 | res.writeHead(400); 109 | res.write('Host header is required'); 110 | return; 111 | } 112 | debugOutput(1, `Received HTTP request for ${host}`); 113 | if (host === process.env.HOME_DOMAIN) { 114 | // handle CORS 115 | res.setHeader('Access-Control-Allow-Origin', '*'); 116 | res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS'); 117 | res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); 118 | res.setHeader('Access-Control-Max-Age', '86400'); 119 | if (req.method === 'OPTIONS') { 120 | res.statusCode = 204; 121 | return; 122 | } 123 | 124 | switch (url) { 125 | case '/stat': 126 | res.writeHead(200, { 'Content-Type': 'application/json' }); 127 | res.write(JSON.stringify(getStat())); 128 | return; 129 | case '/health': 130 | res.writeHead(200, { 'Content-Type': 'text/plain' }); 131 | res.write("ok"); 132 | return; 133 | case '/flushcache': 134 | if (req.method !== 'POST') { 135 | res.writeHead(405, { 'Content-Type': 'text/plain' }); 136 | res.write("Method Not Allowed"); 137 | return; 138 | } 139 | let body = ''; 140 | let totalSize = 0; 141 | 142 | req.on('data', chunk => { 143 | totalSize += chunk.length; 144 | // Disconnect if the data stream is too large 145 | if (totalSize > MAX_DATA_SIZE) { 146 | debugOutput(3, `Data stream for request ${host} too large`); 147 | req.destroy(); 148 | return; 149 | } 150 | 151 | body += chunk.toString(); 152 | }); 153 | 154 | req.on('end', () => { 155 | if (totalSize <= MAX_DATA_SIZE) { 156 | const parsedData = querystring.parse(body); 157 | const domain = parsedData.domain; 158 | 159 | if (!domain || typeof domain !== 'string') { 160 | return; 161 | } 162 | 163 | if (validator.isFQDN(domain)) { 164 | const cacheExists = resolveCache.get(domain); 165 | if (cacheExists) { 166 | // Remove the cache entry 167 | resolveCache.delete(domain); 168 | debugOutput(1, `Cache cleared for ${domain}`); 169 | } 170 | } 171 | } 172 | }); 173 | res.writeHead(200, { 'Content-Type': 'text/plain' }); 174 | res.write("Cache cleared"); 175 | return; 176 | } 177 | 178 | } 179 | let cache = resolveCache.get(host); 180 | if (!cache || (Date.now() > cache.expire)) { 181 | cache = await buildCache(host); 182 | resolveCache.set(host, cache); 183 | debugOutput(1, `No cache found for ${host}, storing new data`); 184 | } else { 185 | debugOutput(1, `Found cache for ${host}, using stored data`); 186 | } 187 | if (cache.blacklisted) { 188 | if (blacklistRedirectUrl) { 189 | res.writeHead(302, { 190 | 'Location': blacklistRedirectUrl + "?d=" + encodeURIComponent(req.headers.host + ""), 191 | }); 192 | } else { 193 | res.writeHead(403); 194 | res.write('Host is forbidden'); 195 | } 196 | return; 197 | } 198 | res.writeHead(cache.httpStatus, { 199 | 'Location': cache.expand ? combineURLs(cache.url, url) : cache.url, 200 | }); 201 | return; 202 | } 203 | 204 | catch (/** @type {any} */ error) { 205 | const message = error?.message; 206 | res.writeHead(message ? 400 : 500); 207 | res.write(message || 'Unknown error'); 208 | } 209 | finally { 210 | res.end(); 211 | } 212 | }; 213 | 214 | export { 215 | listener, 216 | pruneCache, 217 | buildCache, 218 | } 219 | -------------------------------------------------------------------------------- /src/db.js: -------------------------------------------------------------------------------- 1 | 2 | import sqlite from "better-sqlite3"; 3 | import { derToPem, getCertExpiry, initMap, pemToDer } from "./util.js"; 4 | import { migrateFromV2, migrateFromV3 } from "./tools/migrate.js"; 5 | import { dirname } from "node:path"; 6 | 7 | /** 8 | * @typedef {Object} CertConfig 9 | * @property {string} version 10 | * @property {string} accountPrivateKey PEM 11 | * @property {string} accountPublicKey 12 | * 13 | */ 14 | 15 | /** 16 | * @typedef {Object} CertCache 17 | * @property {string} cert PEM 18 | * @property {string} key PEM 19 | * @property {number} expire 20 | * 21 | */ 22 | 23 | /** 24 | * @typedef {Object} CertRow 25 | * @property {string} domain 26 | * @property {Buffer} key DER 27 | * @property {Buffer} cert DER 28 | * @property {Buffer} ca DER 29 | * @property {number} expire 30 | */ 31 | 32 | export class CertsDB { 33 | /** 34 | * @param {string} path 35 | */ 36 | constructor(path) { 37 | const db = sqlite(path); 38 | // stored as BLOB DER format (fewer bytes), but node need PEM 39 | db.prepare(`CREATE TABLE IF NOT EXISTS certs ( 40 | domain TEXT UNIQUE, 41 | key BLOB, 42 | cert BLOB, 43 | ca BLOB, 44 | expire INTEGER 45 | )`).run(); 46 | db.prepare(`CREATE TABLE IF NOT EXISTS config ( 47 | key TEXT PRIMARY KEY, 48 | value TEXT 49 | )`).run(); 50 | 51 | 52 | this.load_conf_stmt = db.prepare(`SELECT * FROM config`) 53 | this.save_conf_stmt = db.prepare(`INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)`) 54 | 55 | this.db = db; 56 | this.config = this.loadConfig(); 57 | if (!this.config.version) { 58 | migrateFromV2(dirname(path), this); 59 | this.config.version = '4'; 60 | this.saveConfig('version', this.config.version); 61 | } else if (this.config.version === '3') { 62 | migrateFromV3(this); 63 | this.config.version = '4'; 64 | this.saveConfig('version', this.config.version); 65 | } 66 | 67 | this.save_cert_stmt = db.prepare(`INSERT OR REPLACE INTO certs (domain, key, cert, ca, expire) VALUES (?, ?, ?, ?, ?)`) 68 | this.load_cert_stmt = db.prepare(`SELECT * FROM certs WHERE domain = ?`) 69 | this.count_cert_stmt = db.prepare(`SELECT COUNT(*) as domains FROM certs`) 70 | } 71 | close() { 72 | this.db.close(); 73 | } 74 | loadConfig() { 75 | const keys = initMap(); 76 | 77 | for (const row of this.load_conf_stmt.all()) { 78 | // @ts-ignore 79 | keys[row.key] = row.value; 80 | } 81 | return keys; 82 | } 83 | /** 84 | * 85 | * @returns {Partial} 86 | */ 87 | getConfig() { 88 | return this.config; 89 | } 90 | /** 91 | * @param {string} key 92 | * @param {string} value 93 | */ 94 | saveConfig(key, value) { 95 | this.config[key] = value; 96 | this.save_conf_stmt.run(key, value); 97 | } 98 | /** 99 | * 100 | * @param {string} domain 101 | * @returns {CertRow} 102 | */ 103 | resolveCert(domain) { 104 | // @ts-ignore 105 | return this.load_cert_stmt.get(domain); 106 | } 107 | /** 108 | * 109 | * @returns {number} 110 | */ 111 | countCert() { 112 | // @ts-ignore 113 | return this.count_cert_stmt.get().domains; 114 | } 115 | /** 116 | * @param {string} domain 117 | * @returns {CertCache} 118 | */ 119 | resolveCertAsCache(domain) { 120 | const row = this.resolveCert(domain); 121 | if (!row) { 122 | throw new Error("Domain not found") 123 | } 124 | return { 125 | cert: derToPem([row.cert, row.ca], "certificate"), 126 | key: derToPem(row.key, "private"), 127 | expire: row.expire, 128 | }; 129 | } 130 | /** 131 | * @param {string} domain 132 | * @param {Buffer} key 133 | * @param {Buffer} cert 134 | * @param {Buffer} ca 135 | * @param {number} expire 136 | * @returns {CertRow} 137 | */ 138 | saveCert(domain, key, cert, ca, expire) { 139 | if (!this.save_cert_stmt) { 140 | throw new Error("DB is not initialized") 141 | } 142 | this.save_cert_stmt.run([domain, 143 | key, 144 | cert, 145 | ca, 146 | expire, 147 | ]); 148 | return { 149 | domain, key, cert, ca, expire 150 | } 151 | } 152 | /** 153 | * @param {string} domain 154 | * @param {string} key 155 | * @param {string} cert 156 | */ 157 | saveCertFromCache(domain, key, cert) { 158 | const keyBuffer = pemToDer(key)[0]; 159 | const certBuffers = pemToDer(cert); 160 | return this.saveCert(domain, 161 | keyBuffer, 162 | certBuffers[0], 163 | certBuffers[1], 164 | getCertExpiry(cert), 165 | ) 166 | } 167 | } 168 | -------------------------------------------------------------------------------- /src/sni.js: -------------------------------------------------------------------------------- 1 | import tls from "tls"; 2 | import path from "path"; 3 | import AsyncLock from 'async-lock'; 4 | import { Client } from "./certnode/lib/index.js"; 5 | import { CertsDB } from "./db.js"; 6 | import { LRUCache } from 'lru-cache' 7 | import { 8 | blacklistRedirectUrl, 9 | isIpAddress, 10 | isHostBlacklisted, 11 | ensureDirSync, 12 | isExceedHostLimit, 13 | isExceedLabelLimit, 14 | validateCAARecords, 15 | derToPem 16 | } from "./util.js"; 17 | 18 | const lock = new AsyncLock(); 19 | // the regex is for Windows shenanigans 20 | const __dirname = new URL('.', import.meta.url).pathname.replace(/^\/([A-Z]:\/)/, '$1'); 21 | const certsDir = path.join(__dirname, '../.certs'); 22 | const dbFile = path.join(__dirname, '../.certs/db.sqlite'); 23 | const client = new Client(); 24 | ensureDirSync(certsDir); 25 | const db = new CertsDB(dbFile); 26 | 27 | /** 28 | * @type {LRUCache} 29 | */ 30 | let resolveCache = new LRUCache({ max: 10000 }); 31 | 32 | 33 | /** 34 | * @type {{domains: number, in_mem: number, iat: number, exp: number}} 35 | */ 36 | let statCache; 37 | 38 | function pruneCache() { 39 | resolveCache = new LRUCache({ max: 10000 }); 40 | } 41 | 42 | function getStat() { 43 | if (statCache && statCache.exp > Date.now()) { 44 | return statCache; 45 | } 46 | statCache = { 47 | domains: db.countCert(), 48 | in_mem: resolveCache.size, 49 | iat: Date.now(), 50 | exp: Date.now() + 1000 * 60 * 60, 51 | }; 52 | return statCache; 53 | } 54 | 55 | /** 56 | * @param {string} host 57 | * @returns {Promise} 58 | */ 59 | async function buildCache(host) { 60 | try { 61 | let data = db.resolveCertAsCache(host); 62 | if (Date.now() > data.expire) 63 | throw new Error('expired'); 64 | return data; 65 | } 66 | catch { 67 | if ((isHostBlacklisted(host) && !blacklistRedirectUrl) || isIpAddress(host)) { 68 | return null; 69 | } 70 | if (isExceedHostLimit(host) || isExceedLabelLimit(host) || await validateCAARecords(host)) { 71 | return null; 72 | } 73 | 74 | // can only process one certificate generation at a time 75 | return await lock.acquire('cert', async () => { 76 | const { cert, key } = await client.generateCertificate(host); 77 | const { expire } = db.saveCertFromCache(host, key, cert); 78 | return { 79 | cert, 80 | key, 81 | expire, 82 | }; 83 | }); 84 | } 85 | } 86 | /** 87 | * @param {string} servername 88 | */ 89 | async function getKeyCert(servername) { 90 | servername = servername.toLowerCase(); 91 | const cache = resolveCache.get(servername); 92 | if (!cache || (Date.now() > cache.expire)) { 93 | let cacheNew = await buildCache(servername); 94 | if (!cacheNew) { 95 | return undefined; 96 | } 97 | resolveCache.set(servername, cacheNew); 98 | return { 99 | key: cacheNew.key, 100 | cert: cacheNew.cert, 101 | } 102 | } 103 | return { 104 | key: cache.key, 105 | cert: cache.cert, 106 | }; 107 | } 108 | /** 109 | * @param {string} servername 110 | * @param {(err: any, cb: tls.SecureContext|undefined) => void} ctx 111 | */ 112 | async function SniListener(servername, ctx) { 113 | // Generate fresh account keys for Let's Encrypt 114 | try { 115 | const keyCert = await getKeyCert(servername); 116 | ctx(null, keyCert && tls.createSecureContext(keyCert)); 117 | } 118 | catch (error) { 119 | console.log(error); 120 | ctx(error, undefined); 121 | } 122 | } 123 | 124 | const SniPrepare = async () => { 125 | let config = db.getConfig(); 126 | if (config.accountPrivateKey && config.accountPublicKey) { 127 | await client.importAccountKeyPair( 128 | config.accountPrivateKey, 129 | config.accountPublicKey, 130 | ''); 131 | } else { 132 | console.log("Creating new account key pair"); 133 | await client.generateAccountKeyPair(); 134 | Object.assign(config, client.exportAccountKeyPair('')); 135 | // Note we save this as PEM format cause this isn't BLOB 136 | db.saveConfig('accountPublicKey', config.accountPublicKey || ''); 137 | db.saveConfig('accountPrivateKey', config.accountPrivateKey || ''); 138 | } 139 | }; 140 | 141 | const SniDispose = () => { 142 | db.close(); 143 | } 144 | 145 | export { 146 | SniListener, 147 | SniPrepare, 148 | SniDispose, 149 | pruneCache, 150 | getStat, 151 | client, 152 | }; 153 | -------------------------------------------------------------------------------- /src/tools/migrate.js: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import path from 'path'; 3 | 4 | /** 5 | * @param {string} dir 6 | * @param {import('../db.js').CertsDB} db 7 | */ 8 | function migrateWalkDir(dir, db, count = 0) { 9 | const files = fs.readdirSync(dir); 10 | /** 11 | * @type {string[]} 12 | */ 13 | let curFiles = [] 14 | files.forEach(file => { 15 | const filePath = path.join(dir, file); 16 | const stat = fs.statSync(filePath); 17 | 18 | if (stat.isDirectory()) { 19 | count = migrateWalkDir(filePath, db, count); // Recurse into subdirectory 20 | } else { 21 | curFiles.push(file); // Add file path to list 22 | } 23 | }); 24 | if (curFiles.includes("expire") && curFiles.includes("privateKey.pem") && curFiles.includes("publicKey.pem")) { 25 | let host = path.basename(dir); 26 | // add to db if not exists 27 | if (!db.resolveCert(host)) { 28 | process.stdout.write(`Added ${++count} domains from v2\r`); 29 | db.saveCertFromCache(host, 30 | fs.readFileSync(path.join(dir, "privateKey.pem"), { encoding: 'utf-8' }), 31 | fs.readFileSync(path.join(dir, "publicKey.pem"), { encoding: 'utf-8' }), 32 | ) 33 | } else { 34 | process.stdout.write(`skipped ${count}\n`); 35 | 36 | } 37 | } 38 | 39 | return count; 40 | } 41 | 42 | /** 43 | * @param {string} dir 44 | * @param {import('../db.js').CertsDB} db 45 | */ 46 | export function migrateFromV2(dir, db) { 47 | // check if v2 account exists, try migrate then. 48 | 49 | if (!fs.existsSync(path.join(dir, "account/privateKey.pem")) || !fs.existsSync(path.join(dir, "account/publicKey.pem"))) { 50 | return; 51 | } 52 | process.stdout.write(`Begin v2 -> v3 migration sessions\n`); 53 | 54 | let config = db.getConfig(); 55 | if (!config.accountPrivateKey || !config.accountPublicKey) { 56 | process.stdout.write(`Account keys imported from v2\n`); 57 | config.accountPrivateKey = fs.readFileSync(path.join(dir, "account/privateKey.pem"), { encoding: 'utf-8' }); 58 | config.accountPublicKey = fs.readFileSync(path.join(dir, "account/publicKey.pem"), { encoding: 'utf-8' }); 59 | db.saveConfig('accountPublicKey', config.accountPublicKey || ''); 60 | db.saveConfig('accountPrivateKey', config.accountPrivateKey || ''); 61 | } 62 | 63 | migrateWalkDir(dir, db); 64 | process.stdout.write(`\nImport completed\n`); 65 | } 66 | 67 | /** 68 | * @param {import('../db.js').CertsDB} db 69 | */ 70 | export function migrateFromV3({ db }) { 71 | // check if v2 account exists, try migrate then. 72 | 73 | process.stdout.write(`Begin v3 -> v4 migration sessions\n`); 74 | 75 | db.prepare(`ALTER TABLE certs ADD COLUMN ca BLOB`).run(); 76 | 77 | process.stdout.write(`\nImport completed\n`); 78 | } 79 | -------------------------------------------------------------------------------- /src/util.js: -------------------------------------------------------------------------------- 1 | import request from "./certnode/lib/request.js"; 2 | import fs from "node:fs"; 3 | import { isIPv4, isIPv6 } from "node:net"; 4 | import { fileURLToPath } from "node:url"; 5 | import dns from 'dns/promises'; 6 | import forge from "node-forge"; 7 | const recordParamDestUrl = 'forward-domain'; 8 | const recordParamHttpStatus = 'http-status'; 9 | const caaRegex = /^0 issue (")?letsencrypt\.org(;validationmethods=http-01)?\1$/; 10 | const validDebugLevels = [0, 1, 2, 3]; 11 | 12 | /** 13 | * @type {Record} 14 | */ 15 | const allowedHttpStatusCodes = { 16 | "301": true, // Legacy Permanent Redirect with POST -> GET 17 | "302": true, // Legacy Temporary Redirect with POST -> GET 18 | "307": true, // Modern Temporary Redirect with POST -> POST 19 | "308": true, // Modern Permanent Redirect with POST -> POST 20 | } 21 | 22 | 23 | /** 24 | * @type {boolean | null} 25 | */ 26 | let useLocalDNS = null 27 | /** 28 | * @type {Record | null} 29 | */ 30 | let blacklistMap = null; 31 | /** 32 | * @type {Record | null} 33 | */ 34 | let whitelistMap = null; 35 | /** 36 | * @type {number | null} 37 | */ 38 | let cacheExpirySeconds = null; 39 | /** 40 | * @type {number | null} 41 | */ 42 | let debugLevel = null 43 | 44 | export function getExpiryDate() { 45 | if (cacheExpirySeconds === null) { 46 | cacheExpirySeconds = parseInt(process.env.CACHE_EXPIRY_SECONDS || '86400') 47 | } 48 | return Date.now() + cacheExpirySeconds * 1000; 49 | } 50 | 51 | /** 52 | * @returns {Record} 53 | */ 54 | export function initMap() { 55 | return {} 56 | } 57 | 58 | /** 59 | * @type {string?} 60 | */ 61 | export let blacklistRedirectUrl = null; 62 | 63 | /** 64 | * @param {string} str 65 | * @return {Array} 66 | */ 67 | function findDotPositions(str) { 68 | let dotPositions = [0]; 69 | let index = str.indexOf('.'); 70 | 71 | while (index !== -1) { 72 | dotPositions.push(index + 1); 73 | index = str.indexOf('.', index + 1); 74 | } 75 | 76 | return dotPositions; 77 | } 78 | 79 | 80 | /** 81 | * @param {string} str 82 | * @return {Record} 83 | */ 84 | function csvToMap(str) { 85 | return (str || "").split(',').reduce((acc, host) => { 86 | host = host.trim().toLowerCase(); 87 | const labelPositions = findDotPositions(host); 88 | for (let i = labelPositions.length; i-- > 0;) { 89 | acc[host.slice(labelPositions[i])] = i == 0 90 | } 91 | return acc; 92 | }, initMap()) 93 | } 94 | 95 | /** 96 | * @return {void} 97 | */ 98 | export function clearConfig() { 99 | whitelistMap = null; 100 | blacklistMap = null; 101 | useLocalDNS = null; 102 | blacklistRedirectUrl = null; 103 | cacheExpirySeconds = null; 104 | debugLevel = null; 105 | } 106 | 107 | /** 108 | * @param {Record} [mockEnv] 109 | */ 110 | export function isHostBlacklisted(domain = '', mockEnv = undefined) { 111 | if (blacklistMap === null || mockEnv) { 112 | let env = mockEnv || process.env; 113 | if (env.WHITELIST_HOSTS) { 114 | whitelistMap = csvToMap(env.WHITELIST_HOSTS || ""); 115 | } 116 | blacklistMap = csvToMap(env.BLACKLIST_HOSTS || ""); 117 | blacklistRedirectUrl = env.BLACKLIST_REDIRECT || null; 118 | } 119 | const labelPositions = findDotPositions(domain); 120 | if (whitelistMap === null) { 121 | for (let i = labelPositions.length; i-- > 0;) { 122 | let val = blacklistMap[domain.slice(labelPositions[i])] 123 | if (val === false) { 124 | continue; 125 | } else if (val === true) { 126 | return true; 127 | } else { 128 | return false; 129 | } 130 | } 131 | return false; 132 | } else { 133 | for (let i = labelPositions.length; i-- > 0;) { 134 | let val = whitelistMap[domain.slice(labelPositions[i])] 135 | if (val === false) { 136 | continue; 137 | } else if (val === true) { 138 | return false; 139 | } else { 140 | return true; 141 | } 142 | } 143 | return true; 144 | } 145 | } 146 | 147 | /** 148 | * Returns the current date and time in the format: "YYYY-MM-DD HH:MM:SS". 149 | * 150 | * @returns {string} The current date and time as a formatted string. 151 | */ 152 | export function CurrentDate() { 153 | const now = new Date(); 154 | 155 | return ( 156 | now.getFullYear() + '-' + 157 | String(now.getMonth() + 1).padStart(2, '0') + '-' + 158 | String(now.getDate()).padStart(2, '0') + ' ' + 159 | String(now.getHours()).padStart(2, '0') + ':' + 160 | String(now.getMinutes()).padStart(2, '0') + ':' + 161 | String(now.getSeconds()).padStart(2, '0') 162 | ); 163 | } 164 | 165 | /** 166 | * Outputs debug messages to the console based on the specified debug level. 167 | * Debugging is controlled by the `DEBUG_LEVEL` environment variable. 168 | * 169 | * @param {number} level - The debug level of the message (1 - 3). 170 | * @param {string} msg - The debug message to output. 171 | * 172 | */ 173 | export function debugOutput(level,msg) { 174 | if (debugLevel === null) { 175 | debugLevel = validDebugLevels.includes(parseInt(process.env.DEBUG_LEVEL)) ? parseInt(process.env.DEBUG_LEVEL) : 0; 176 | } 177 | if (debugLevel >= 1) { 178 | const date = CurrentDate(); 179 | if (level <= debugLevel) { 180 | console.log(`[${date}] ${msg}`); 181 | } 182 | } 183 | } 184 | 185 | /** 186 | * @param {string} host 187 | */ 188 | export function isIpAddress(host) { 189 | return isIPv4(host) || isIPv6(host) 190 | } 191 | /** 192 | * @param {string} host 193 | */ 194 | export function isExceedLabelLimit(host) { 195 | return [...host].filter(x => x === '.').length >= 10 196 | } 197 | /** 198 | * @param {string} host 199 | */ 200 | export function isExceedHostLimit(host) { 201 | return host.length > 64 202 | } 203 | /** 204 | * @param {string} code 205 | */ 206 | export function isHttpCodeAllowed(code) { 207 | return allowedHttpStatusCodes[code] || false 208 | } 209 | /** 210 | * @param {fs.PathLike} dir 211 | */ 212 | export function ensureDirSync(dir) { 213 | try { 214 | fs.accessSync(dir, fs.constants.W_OK | fs.constants.O_DIRECTORY); 215 | } 216 | catch (error) { 217 | fs.mkdirSync(dir, { 218 | recursive: true 219 | }); 220 | } 221 | } 222 | 223 | /** 224 | * @param {string} value 225 | */ 226 | const parseTxtRecordData = (value) => { 227 | /** 228 | * @type {Record} 229 | */ 230 | const result = {}; 231 | for (const part of value.split(';')) { 232 | const [key, ...value] = part.split('='); 233 | if (key && value.length > 0) { 234 | result[key] = value.join('='); 235 | } 236 | } 237 | return result; 238 | } 239 | 240 | /** 241 | * @param {string} host 242 | * @param {any} mockResolve 243 | * @return {Promise} 244 | */ 245 | export async function validateCAARecords(host, mockResolve = undefined) { 246 | if (useLocalDNS === null) { 247 | useLocalDNS = process.env.USE_LOCAL_DNS == 'true'; 248 | debugOutput(3, `useLocalDNS: ${useLocalDNS}`); 249 | } 250 | let issueRecords; 251 | if (useLocalDNS && !mockResolve) { 252 | const records = mockResolve || await dns.resolveCaa(host).catch(() => null); 253 | issueRecords = (records || []).filter(record => record.issue).map(record => `0 issue "${record.issue}"`); 254 | 255 | } else { 256 | /** 257 | * @type {{data: {Answer: {data: string, type: number}[]}}} 258 | */ 259 | const resolve = mockResolve || await request(`https://dns.google/resolve?name=${encodeURIComponent(host)}&type=CAA`); 260 | if (!resolve.data.Answer) { 261 | return null; 262 | } 263 | 264 | issueRecords = resolve.data.Answer.filter((x) => 265 | x.type == 257 && typeof x.data === 'string' && x.data.startsWith('0 issue ') 266 | ).map(x => x.data); 267 | } 268 | 269 | // Check if any record allows Let's Encrypt (or no record at all) 270 | if (issueRecords.length === 0 || issueRecords.some(record => caaRegex.test(record))) { 271 | return null; 272 | } 273 | 274 | debugOutput(3, `issueRecords for ${host}: ${issueRecords}`); 275 | return issueRecords; 276 | } 277 | 278 | /** 279 | * @param {string} host 280 | * @param {any} mockResolve 281 | * @return {Promise<{url: string, httpStatus?: string} | null>} 282 | */ 283 | export async function findTxtRecord(host, mockResolve = undefined) { 284 | if (useLocalDNS === null) { 285 | useLocalDNS = process.env.USE_LOCAL_DNS == 'true'; 286 | } 287 | if (useLocalDNS && !mockResolve) { 288 | const resolvePromises = [ 289 | dns.resolveTxt(`_.${host}`), 290 | dns.resolveTxt(`fwd.${host}`) 291 | ]; 292 | 293 | const resolved = await Promise.any(resolvePromises).catch(() => null); 294 | 295 | if (resolved) { 296 | debugOutput(2, `findTxtRecord for ${host}: ${resolved}`); 297 | for (const record of resolved) { 298 | const joinedRecord = record.join(';'); 299 | const txtData = parseTxtRecordData(joinedRecord); 300 | if (!txtData[recordParamDestUrl]) continue; 301 | return { 302 | url: txtData[recordParamDestUrl], 303 | httpStatus: txtData[recordParamHttpStatus], 304 | }; 305 | } 306 | } 307 | } else { 308 | /** 309 | * @type {{data: string, type: number}[]} 310 | */ 311 | const resolve = mockResolve ? mockResolve.data.Answer : [ 312 | ...(await request(`https://dns.google/resolve?name=_.${encodeURIComponent(host)}&type=TXT`)).data.Answer || [], 313 | ...(await request(`https://dns.google/resolve?name=fwd.${encodeURIComponent(host)}&type=TXT`)).data.Answer || [], 314 | ]; 315 | for (const head of resolve) { 316 | if (head.type !== 16) { // RR type of TXT is 16 317 | continue; 318 | } 319 | const txtData = parseTxtRecordData(head.data); 320 | if (!txtData[recordParamDestUrl]) continue; 321 | debugOutput(2, `findTxtRecord for ${host}: ${txtData[recordParamDestUrl]}`); 322 | return { 323 | url: txtData[recordParamDestUrl], 324 | httpStatus: txtData[recordParamHttpStatus], 325 | }; 326 | } 327 | } 328 | return null; 329 | } 330 | 331 | /** 332 | * 333 | * @param {string} cert 334 | */ 335 | export function getCertExpiry(cert) { 336 | const x509 = forge.pki.certificateFromPem(cert); 337 | debugOutput(3, `LE cert expire after: ${x509.validity.notAfter.getTime()}`); 338 | return x509.validity.notAfter.getTime() 339 | } 340 | 341 | /** 342 | * 343 | * @param {string} key 344 | */ 345 | export function pemToDer(key) { 346 | const keys = forge.pem.decode(key); 347 | return keys.map(x => Buffer.from(x.body, 'binary')); 348 | } 349 | 350 | /** 351 | * @param {Buffer|Buffer[]} derBuffer 352 | * @param {"public"|"private"|"certificate"} type 353 | * @returns {string} 354 | */ 355 | export function derToPem(derBuffer, type) { 356 | if (Array.isArray(derBuffer)) { 357 | return derBuffer.filter(x => x && x.length > 0).map(x => derToPem(x, type)).join(''); 358 | } 359 | const prefix = { 360 | 'certificate': 'CERTIFICATE', 361 | 'public': 'PUBLIC KEY', 362 | 'private': 'PRIVATE KEY' 363 | }[type]; 364 | 365 | const header = `-----BEGIN ${prefix}-----\n`; 366 | const footer = `-----END ${prefix}-----\n`; 367 | 368 | const base64Content = derBuffer.toString('base64').match(/.{0,64}/g) || []; 369 | const pemContent = `${header}${base64Content.join('\n')}${footer}`; 370 | return pemContent; 371 | } 372 | 373 | /** 374 | * @param {string} baseURL 375 | * @param {string} relativeURL 376 | */ 377 | export function combineURLs(baseURL, relativeURL) { 378 | if (!relativeURL) { 379 | return baseURL; 380 | } 381 | return baseURL.replace(/\/+$/, '') + '/' + relativeURL.replace(/^\/+/, ''); 382 | } 383 | 384 | /** 385 | * @param {string} metaURL 386 | */ 387 | export function isMainProcess(metaURL) { 388 | return [process.argv[1], process.env.pm_exec_path].includes(fileURLToPath(metaURL)); 389 | } 390 | -------------------------------------------------------------------------------- /test/certs/README.md: -------------------------------------------------------------------------------- 1 | # certs/ 2 | 3 | This directory contains a CA certificate (`pebble.minica.pem`) and a private key 4 | (`pebble.minica.key.pem`) that are used to issue a end-entity certificate (See 5 | `certs/localhost`) for the Pebble HTTPS server. 6 | 7 | To get your **testing code** to use Pebble without HTTPS errors you should 8 | configure your ACME client to trust the `pebble.minica.pem` CA certificate. Your 9 | ACME client should offer a runtime option to specify a list of root CAs that you 10 | can configure to include the `pebble.minica.pem` file. 11 | 12 | **Do not** add this CA certificate to the system trust store or in production 13 | code!!! The CA's private key is **public** and anyone can use it to issue 14 | certificates that will be trusted by a system with the Pebble CA in the trust 15 | store. 16 | 17 | To re-create all of the Pebble certificates run: 18 | 19 | minica -ca-cert pebble.minica.pem \ 20 | -ca-key pebble.minica.key.pem \ 21 | -domains localhost,pebble \ 22 | -ip-addresses 127.0.0.1 23 | 24 | From the `test/certs/` directory after [installing 25 | MiniCA](https://github.com/jsha/minica#installation) 26 | -------------------------------------------------------------------------------- /test/certs/localhost/README.md: -------------------------------------------------------------------------------- 1 | # certs/localhost 2 | 3 | This directory contains an end-entity (leaf) certificate (`cert.pem`) and 4 | a private key (`key.pem`) for the Pebble HTTPS server. It includes `127.0.0.1` 5 | as an IP address SAN, and `[localhost, pebble]` as DNS SANs. 6 | -------------------------------------------------------------------------------- /test/certs/localhost/cert.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIDGzCCAgOgAwIBAgIIbEfayDFsBtwwDQYJKoZIhvcNAQELBQAwIDEeMBwGA1UE 3 | AxMVbWluaWNhIHJvb3QgY2EgMjRlMmRiMCAXDTE3MTIwNjE5NDIxMFoYDzIxMDcx 4 | MjA2MTk0MjEwWjAUMRIwEAYDVQQDEwlsb2NhbGhvc3QwggEiMA0GCSqGSIb3DQEB 5 | AQUAA4IBDwAwggEKAoIBAQCbFMW3DXXdErvQf2lCZ0qz0DGEWadDoF0O2neM5mVa 6 | VQ7QGW0xc5Qwvn3Tl62C0JtwLpF0pG2BICIN+DHdVaIUwkf77iBS2doH1I3waE1I 7 | 8GkV9JrYmFY+j0dA1SwBmqUZNXhLNwZGq1a91nFSI59DZNy/JciqxoPX2K++ojU2 8 | FPpuXe2t51NmXMsszpa+TDqF/IeskA9A/ws6UIh4Mzhghx7oay2/qqj2IIPjAmJj 9 | i73kdUvtEry3wmlkBvtVH50+FscS9WmPC5h3lDTk5nbzSAXKuFusotuqy3XTgY5B 10 | PiRAwkZbEY43JNfqenQPHo7mNTt29i+NVVrBsnAa5ovrAgMBAAGjYzBhMA4GA1Ud 11 | DwEB/wQEAwIFoDAdBgNVHSUEFjAUBggrBgEFBQcDAQYIKwYBBQUHAwIwDAYDVR0T 12 | AQH/BAIwADAiBgNVHREEGzAZgglsb2NhbGhvc3SCBnBlYmJsZYcEfwAAATANBgkq 13 | hkiG9w0BAQsFAAOCAQEAYIkXff8H28KS0KyLHtbbSOGU4sujHHVwiVXSATACsNAE 14 | D0Qa8hdtTQ6AUqA6/n8/u1tk0O4rPE/cTpsM3IJFX9S3rZMRsguBP7BSr1Lq/XAB 15 | 7JP/CNHt+Z9aKCKcg11wIX9/B9F7pyKM3TdKgOpqXGV6TMuLjg5PlYWI/07lVGFW 16 | /mSJDRs8bSCFmbRtEqc4lpwlrpz+kTTnX6G7JDLfLWYw/xXVqwFfdengcDTHCc8K 17 | wtgGq/Gu6vcoBxIO3jaca+OIkMfxxXmGrcNdseuUCa3RMZ8Qy03DqGu6Y6XQyK4B 18 | W8zIG6H9SVKkAznM2yfYhW8v2ktcaZ95/OBHY97ZIw== 19 | -----END CERTIFICATE----- 20 | -------------------------------------------------------------------------------- /test/certs/localhost/key.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA PRIVATE KEY----- 2 | MIIEowIBAAKCAQEAmxTFtw113RK70H9pQmdKs9AxhFmnQ6BdDtp3jOZlWlUO0Blt 3 | MXOUML5905etgtCbcC6RdKRtgSAiDfgx3VWiFMJH++4gUtnaB9SN8GhNSPBpFfSa 4 | 2JhWPo9HQNUsAZqlGTV4SzcGRqtWvdZxUiOfQ2TcvyXIqsaD19ivvqI1NhT6bl3t 5 | redTZlzLLM6Wvkw6hfyHrJAPQP8LOlCIeDM4YIce6Gstv6qo9iCD4wJiY4u95HVL 6 | 7RK8t8JpZAb7VR+dPhbHEvVpjwuYd5Q05OZ280gFyrhbrKLbqst104GOQT4kQMJG 7 | WxGONyTX6np0Dx6O5jU7dvYvjVVawbJwGuaL6wIDAQABAoIBAGW9W/S6lO+DIcoo 8 | PHL+9sg+tq2gb5ZzN3nOI45BfI6lrMEjXTqLG9ZasovFP2TJ3J/dPTnrwZdr8Et/ 9 | 357YViwORVFnKLeSCnMGpFPq6YEHj7mCrq+YSURjlRhYgbVPsi52oMOfhrOIJrEG 10 | ZXPAwPRi0Ftqu1omQEqz8qA7JHOkjB2p0i2Xc/uOSJccCmUDMlksRYz8zFe8wHuD 11 | XvUL2k23n2pBZ6wiez6Xjr0wUQ4ESI02x7PmYgA3aqF2Q6ECDwHhjVeQmAuypMF6 12 | IaTjIJkWdZCW96pPaK1t+5nTNZ+Mg7tpJ/PRE4BkJvqcfHEOOl6wAE8gSk5uVApY 13 | ZRKGmGkCgYEAzF9iRXYo7A/UphL11bR0gqxB6qnQl54iLhqS/E6CVNcmwJ2d9pF8 14 | 5HTfSo1/lOXT3hGV8gizN2S5RmWBrc9HBZ+dNrVo7FYeeBiHu+opbX1X/C1HC0m1 15 | wJNsyoXeqD1OFc1WbDpHz5iv4IOXzYdOdKiYEcTv5JkqE7jomqBLQk8CgYEAwkG/ 16 | rnwr4ThUo/DG5oH+l0LVnHkrJY+BUSI33g3eQ3eM0MSbfJXGT7snh5puJW0oXP7Z 17 | Gw88nK3Vnz2nTPesiwtO2OkUVgrIgWryIvKHaqrYnapZHuM+io30jbZOVaVTMR9c 18 | X/7/d5/evwXuP7p2DIdZKQKKFgROm1XnhNqVgaUCgYBD/ogHbCR5RVsOVciMbRlG 19 | UGEt3YmUp/vfMuAsKUKbT2mJM+dWHVlb+LZBa4pC06QFgfxNJi/aAhzSGvtmBEww 20 | xsXbaceauZwxgJfIIUPfNZCMSdQVIVTi2Smcx6UofBz6i/Jw14MEwlvhamaa7qVf 21 | kqflYYwelga1wRNCPopLaQKBgQCWsZqZKQqBNMm0Q9yIhN+TR+2d7QFjqeePoRPl 22 | 1qxNejhq25ojE607vNv1ff9kWUGuoqSZMUC76r6FQba/JoNbefI4otd7x/GzM9uS 23 | 8MHMJazU4okwROkHYwgLxxkNp6rZuJJYheB4VDTfyyH/ng5lubmY7rdgTQcNyZ5I 24 | majRYQKBgAMKJ3RlII0qvAfNFZr4Y2bNIq+60Z+Qu2W5xokIHCFNly3W1XDDKGFe 25 | CCPHSvQljinke3P9gPt2HVdXxcnku9VkTti+JygxuLkVg7E0/SWwrWfGsaMJs+84 26 | fK+mTZay2d3v24r9WKEKwLykngYPyZw5+BdWU0E+xx5lGUd3U4gG 27 | -----END RSA PRIVATE KEY----- 28 | -------------------------------------------------------------------------------- /test/certs/pebble.minica.key.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA PRIVATE KEY----- 2 | MIIEpAIBAAKCAQEAuVoGTaFSWp3Y+N5JC8lOdL8wmWpaM73UaNzhYiqA7ZqijzVk 3 | TTtoQvQFDcUwyXKOdWHONrv1ld3z224Us504jjlbZwI5uoquCOZ2WJbRhmXrRgzk 4 | Fq+/MtoFmPkhtO/DLjjtocgyIirVXN8Yl2APvB5brvRfCm6kktYeecsWfW/O3ikf 5 | gdM7tmocwQiBypiloHOjdd5e2g8cWNw+rqvILSUVNLaLpsi23cxnLqVb424wz9dZ 6 | 5dO0REg1gSxtf4N5LSb6iGuAVoFNhzIeKzQ+svDg9x8tx/DGOghJS/jDgmxSY1qo 7 | bTsXhcmWVfat5GJ5PQgLkCSjBBrjeBlOrc4VtQIDAQABAoIBAQCAoRoou6C0ZEDU 8 | DScyN8TrvlcS0LzClaWYFFmRT5/jxOG1cr8l3elwNXpgYQ2Hb6mvim2ajHxVQg/e 9 | oxlYwO4jvWhSJzg63c0DPjS5LAlCNO6+0Wlk2RheSPGDhLlAoPeZ10YKdS1dis5B 10 | Qk4Fl1O0IHlOBCcEzV4GzPOfYDI+X6/f4xY7qz1s+CgoIxjIeiG+1/WpZQpYhobY 11 | 7CfSDdYDKtksXi7iQkc5earUAHBqZ1gQTq6e5LVm9AjRzENhMctFgcPs5zOjp2ak 12 | PluixrA8LTAfu9wQzvxDkPl0UarZVxCerw6nlAziILpQ+U6PtoPZj49VpntTc+cq 13 | 1qjzkbhBAoGBANElJmFWY2X6LgBpszeqt0ZOSbkFg2bC0wHCJrMlRzUMEn83w9e8 14 | Z2Fqml9eCC5qxJcyxWDVQeoAX6090m0qgP8xNmGdafcVic2cUlrqtkqhhst2OHCO 15 | MCQEB7cdsjiidNNrOgLbQ3i1bYID8BVLf/TDhEbRgvTewDaz6XPdoSIRAoGBAOLg 16 | RuOec5gn50SrVycx8BLFO8AXjXojpZb1Xg26V5miz1IavSfDcgae/699ppSz+UWi 17 | jGMFr/PokY2JxDVs3PyQLu7ahMzyFHr16Agvp5g5kq056XV+uI/HhqLHOWSQ09DS 18 | 1Vrj7FOYpKRzge3/AC7ty9Vr35uMiebpm4/CLFVlAoGALnsIJZfSbWaFdLgJCXUa 19 | WDir77/G7T6dMIXanfPJ+IMfVUCqeLa5bxAHEOzP+qjl2giBjzy18nB00warTnGk 20 | y5I/WMBoPW5++sAkGWqSatGtKGi0sGcZUdfHcy3ZXvbT6eyprtrWCuyfUsbXQ5RM 21 | 8rPFIQwNA6jBpSak2ohF+FECgYEAn+6IKncNd6pRfnfmdSvf1+uPxkcUJZCxb2xC 22 | xByjGhvKWE+fHkPJwt8c0SIbZuJEC5Gds0RUF/XPfV4roZm/Yo9ldl02lp7kTxXA 23 | XtzxIP8c5d5YM8qD4l8+Csu0Kq9pkeC+JFddxkRpc8A1TIehInPhZ+6mb6mvoMb3 24 | MW0pAX0CgYATT74RYuIYWZvx0TK4ZXIKTw2i6HObLF63Y6UwyPXXdEVie/ToYRNH 25 | JIxE1weVpHvnHZvVD6D3yGk39ZsCIt31VvKpatWXlWBm875MbBc6kuIGsYT+mSSj 26 | y9TXaE89E5zfL27nZe15QLJ+Xw8Io6PMLZ/jtC5TYoEixSZ9J8v6HA== 27 | -----END RSA PRIVATE KEY----- 28 | -------------------------------------------------------------------------------- /test/certs/pebble.minica.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIDCTCCAfGgAwIBAgIIJOLbes8sTr4wDQYJKoZIhvcNAQELBQAwIDEeMBwGA1UE 3 | AxMVbWluaWNhIHJvb3QgY2EgMjRlMmRiMCAXDTE3MTIwNjE5NDIxMFoYDzIxMTcx 4 | MjA2MTk0MjEwWjAgMR4wHAYDVQQDExVtaW5pY2Egcm9vdCBjYSAyNGUyZGIwggEi 5 | MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC5WgZNoVJandj43kkLyU50vzCZ 6 | alozvdRo3OFiKoDtmqKPNWRNO2hC9AUNxTDJco51Yc42u/WV3fPbbhSznTiOOVtn 7 | Ajm6iq4I5nZYltGGZetGDOQWr78y2gWY+SG078MuOO2hyDIiKtVc3xiXYA+8Hluu 8 | 9F8KbqSS1h55yxZ9b87eKR+B0zu2ahzBCIHKmKWgc6N13l7aDxxY3D6uq8gtJRU0 9 | toumyLbdzGcupVvjbjDP11nl07RESDWBLG1/g3ktJvqIa4BWgU2HMh4rND6y8OD3 10 | Hy3H8MY6CElL+MOCbFJjWqhtOxeFyZZV9q3kYnk9CAuQJKMEGuN4GU6tzhW1AgMB 11 | AAGjRTBDMA4GA1UdDwEB/wQEAwIChDAdBgNVHSUEFjAUBggrBgEFBQcDAQYIKwYB 12 | BQUHAwIwEgYDVR0TAQH/BAgwBgEB/wIBADANBgkqhkiG9w0BAQsFAAOCAQEAF85v 13 | d40HK1ouDAtWeO1PbnWfGEmC5Xa478s9ddOd9Clvp2McYzNlAFfM7kdcj6xeiNhF 14 | WPIfaGAi/QdURSL/6C1KsVDqlFBlTs9zYfh2g0UXGvJtj1maeih7zxFLvet+fqll 15 | xseM4P9EVJaQxwuK/F78YBt0tCNfivC6JNZMgxKF59h0FBpH70ytUSHXdz7FKwix 16 | Mfn3qEb9BXSk0Q3prNV5sOV3vgjEtB4THfDxSz9z3+DepVnW3vbbqwEbkXdk3j82 17 | 2muVldgOUgTwK8eT+XdofVdntzU/kzygSAtAQwLJfn51fS1GvEcYGBc1bDryIqmF 18 | p9BI7gVKtWSZYegicA== 19 | -----END CERTIFICATE----- 20 | -------------------------------------------------------------------------------- /test/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/willnode/forward-domain/test/v2 2 | 3 | go 1.22 4 | -------------------------------------------------------------------------------- /test/go.sum: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/willnode/forward-domain/b642987d31ee140e174752b012dc2105debce6cb/test/go.sum -------------------------------------------------------------------------------- /test/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "crypto/tls" 5 | "fmt" 6 | "io" 7 | "net/http" 8 | "os" 9 | "os/exec" 10 | "path/filepath" 11 | "time" 12 | ) 13 | 14 | func main() { 15 | // Get the current working directory. 16 | currentDir, err := os.Getwd() 17 | if err != nil { 18 | fmt.Println("Error:", err) 19 | return 20 | } 21 | 22 | // Get the parent directory. 23 | parentDir := filepath.Dir(currentDir) 24 | 25 | dns := exec.Command("dnsserver") 26 | dns.Stderr = os.Stderr 27 | // dns.Stdout = os.Stdout 28 | 29 | err = dns.Start() 30 | if err != nil { 31 | fmt.Println("Server Error:", err) 32 | os.Exit(1) 33 | } 34 | 35 | chall := exec.Command("pebble", "-dnsserver", "127.0.0.1:1053", "-config", "pebble-config.json") 36 | chall.Stderr = os.Stderr 37 | chall.Stdout = os.Stdout 38 | chall.Env = append(chall.Env, "PEBBLE_WFE_NONCEREJECT=0") 39 | 40 | err = chall.Start() 41 | if err != nil { 42 | fmt.Println("Server Error:", err) 43 | os.Exit(1) 44 | } 45 | 46 | time.Sleep(time.Millisecond * 1000) 47 | 48 | serv := exec.Command("node", "--env-file=.env.test", "app.js") 49 | serv.Env = os.Environ() 50 | serv.Env = append(serv.Env, "NODE_EXTRA_CA_CERTS=test/certs/pebble.minica.pem") 51 | serv.Dir = parentDir 52 | serv.Stderr = os.Stderr 53 | serv.Stdout = os.Stdout 54 | 55 | err = serv.Start() 56 | if err != nil { 57 | fmt.Println("Server Error:", err) 58 | os.Exit(1) 59 | } 60 | 61 | time.Sleep(time.Millisecond * 5000) 62 | 63 | err = test() 64 | 65 | time.Sleep(time.Millisecond * 1000) 66 | 67 | serv.Process.Kill() 68 | dns.Process.Kill() 69 | exec.Command("killall", "pebble").Run() 70 | if err != nil { 71 | fmt.Println("Test Error:", err) 72 | os.Exit(1) 73 | } 74 | 75 | fmt.Println("Test Successfull!!!!") 76 | os.Exit(0) 77 | } 78 | 79 | func test() error { 80 | url := "http://localhost:8880/hello" 81 | 82 | tr := &http.Transport{ 83 | TLSClientConfig: &tls.Config{ 84 | InsecureSkipVerify: true, 85 | ServerName: "r.forwarddomain.net", 86 | }, 87 | } 88 | client := &http.Client{ 89 | CheckRedirect: func(req *http.Request, via []*http.Request) error { 90 | return http.ErrUseLastResponse 91 | }, 92 | Transport: tr, 93 | } 94 | 95 | req, err := http.NewRequest("GET", url, nil) 96 | if err != nil { 97 | return err 98 | } 99 | 100 | req.Host = "r.forwarddomain.net" 101 | req.Header.Add("accept", "text/plain") 102 | req.Header.Add("user-agent", "test") 103 | 104 | resp, err := client.Do(req) 105 | if err != nil { 106 | return err 107 | } 108 | defer resp.Body.Close() 109 | 110 | bodyBytes, err := io.ReadAll(resp.Body) 111 | if err != nil { 112 | return err 113 | } 114 | bodyString := string(bodyBytes) 115 | fmt.Println(bodyString) 116 | 117 | if resp.StatusCode != 302 { 118 | return fmt.Errorf("expected status code 302, got %d", resp.StatusCode) 119 | } 120 | 121 | location, ok := resp.Header["Location"] 122 | if !ok || location[0] != "https://forwarddomain.net/hello" { 123 | return fmt.Errorf("expected header Location to be 'https://forwarddomain.net/hello', got %s", location[0]) 124 | } 125 | 126 | url = "https://localhost:8843/hello" 127 | 128 | req, err = http.NewRequest("GET", url, nil) 129 | if err != nil { 130 | return err 131 | } 132 | req.Host = "r.forwarddomain.net" 133 | req.Header.Add("accept", "text/plain") 134 | req.Header.Add("user-agent", "test") 135 | 136 | resp, err = client.Do(req) 137 | if err != nil { 138 | return err 139 | } 140 | defer resp.Body.Close() 141 | 142 | if resp.StatusCode != 302 { 143 | return fmt.Errorf("expected status code 302, got %d", resp.StatusCode) 144 | } 145 | 146 | location, ok = resp.Header["Location"] 147 | if !ok || location[0] != "https://forwarddomain.net/hello" { 148 | return fmt.Errorf("expected header Location to be 'https://forwarddomain.net/hello', got %s", location[0]) 149 | } 150 | 151 | // second time: check reuse 152 | resp, err = client.Do(req) 153 | if err != nil { 154 | return err 155 | } 156 | defer resp.Body.Close() 157 | 158 | if resp.StatusCode != 302 { 159 | return fmt.Errorf("expected status code 302 on reuse, got %d", resp.StatusCode) 160 | } 161 | 162 | return nil 163 | } 164 | -------------------------------------------------------------------------------- /test/names.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name": "r.forwarddomain.net", 4 | "address": "127.0.0.1" 5 | } 6 | ] -------------------------------------------------------------------------------- /test/pebble-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "pebble": { 3 | "listenAddress": "0.0.0.0:14000", 4 | "managementListenAddress": "0.0.0.0:15000", 5 | "certificate": "certs/localhost/cert.pem", 6 | "privateKey": "certs/localhost/key.pem", 7 | "httpPort": 8880, 8 | "tlsPort": 8843, 9 | "ocspResponderURL": "", 10 | "externalAccountBindingRequired": false, 11 | "domainBlocklist": ["blocked-domain.example"], 12 | "retryAfter": { 13 | "authz": 3, 14 | "order": 5 15 | }, 16 | "certificateValidityPeriod": 157766400 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /test/unit.test.js: -------------------------------------------------------------------------------- 1 | import { expect, test } from "bun:test"; 2 | import { 3 | isIpAddress, 4 | isExceedLabelLimit, 5 | findTxtRecord, 6 | isHostBlacklisted, 7 | validateCAARecords, 8 | isExceedHostLimit, 9 | derToPem, 10 | pemToDer, 11 | getCertExpiry, 12 | } from "../src/util.js"; 13 | 14 | test('blacklist works', () => { 15 | const mockEnv = { 16 | BLACKLIST_HOSTS: "evil.domain,evil.sub.domain" 17 | } 18 | expect(isHostBlacklisted("evil.domain", mockEnv)).toBe(true); 19 | expect(isHostBlacklisted("evil.sub.domain", mockEnv)).toBe(true); 20 | expect(isHostBlacklisted("sub.evil.sub.domain", mockEnv)).toBe(true); 21 | expect(isHostBlacklisted("suba.suba.evil.sub.domain", mockEnv)).toBe(true); 22 | expect(isHostBlacklisted("sub.evil.domain", mockEnv)).toBe(true); 23 | expect(isHostBlacklisted("domain.evil", mockEnv)).toBe(false); 24 | expect(isHostBlacklisted("sub.domain", mockEnv)).toBe(false); 25 | expect(isHostBlacklisted("domain", mockEnv)).toBe(false); 26 | }); 27 | 28 | test('whitelist works', () => { 29 | const mockEnv = { 30 | WHITELIST_HOSTS: "my.domain,my.sub.domain" 31 | } 32 | expect(isHostBlacklisted("my.domain", mockEnv)).toBe(false); 33 | expect(isHostBlacklisted("my.sub.domain", mockEnv)).toBe(false); 34 | expect(isHostBlacklisted("sub.my.sub.domain", mockEnv)).toBe(false); 35 | expect(isHostBlacklisted("suba.suba.my.sub.domain", mockEnv)).toBe(false); 36 | expect(isHostBlacklisted("sub.my.domain", mockEnv)).toBe(false); 37 | expect(isHostBlacklisted("domain.my", mockEnv)).toBe(true); 38 | expect(isHostBlacklisted("sub.domain", mockEnv)).toBe(true); 39 | expect(isHostBlacklisted("domain", mockEnv)).toBe(true); 40 | }); 41 | 42 | test('client validation works', async () => { 43 | expect(isIpAddress("1.2.3.4")).toBe(true); 44 | expect(isIpAddress("::1")).toBe(true); 45 | expect(isExceedLabelLimit("a.b.c.d.e.f.g.h.i.j.net")).toBe(true); 46 | expect(isExceedHostLimit("very-long-domain-you-will-never-encounter-anyway.yes-it-happened-tho.net")).toBe(true); 47 | }); 48 | 49 | test('caa resolver works', async () => { 50 | expect(await validateCAARecords("forwarddomain.net")).toBe(null); 51 | expect((await validateCAARecords("github.com"))?.sort()).toEqual([ 52 | "0 issue \"digicert.com\"", 53 | "0 issue \"globalsign.com\"", 54 | "0 issue \"sectigo.com\"" 55 | ]); 56 | }); 57 | 58 | test('txt resolver works', async () => { 59 | expect(await findTxtRecord("example.com")).toEqual(null); 60 | expect(await findTxtRecord("r.forwarddomain.net")).toEqual({ 61 | url: "https://forwarddomain.net/*", 62 | httpStatus: '302', 63 | }); 64 | }); 65 | 66 | test('pem der conversion for cert works', async () => { 67 | let cert = `-----BEGIN CERTIFICATE----- 68 | MIIDTjCCAjagAwIBAgIIBgAQy/0qNmkwDQYJKoZIhvcNAQELBQAwKDEmMCQGA1UE 69 | AxMdUGViYmxlIEludGVybWVkaWF0ZSBDQSA2NjRhMmMwHhcNMjQwODAzMDYyMzI3 70 | WhcNMjkwODAzMDYyMzI2WjAAMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKC 71 | AQEAxcls2hAh1rgy7cfjklKeIvsj5hmUhKWPUG/9CBERWiTlJS04vJdWW6/w8f38 72 | CN/fttWlWVYeCag+hGVmShUjBUnuYrzeCO2rq1dkgqhrTW9bFpAhkmtVxieSrXuU 73 | mugG+Q7MW/KwyQxz5fThbNM8Fjn97eerZgzSLDj90BgYVB7Bm5FmqfpoDJRBKayA 74 | W5wrzDgmwUkbsu8ji1Dx9NTjoEhl3+7sjTCxVVJYHPdb/ISPcE5bRcVLT/oA5xXt 75 | L+f4n+/Lydnc9wwcB4cUo2X2Y17CtdxGLkn2XJcjh1Ca0iMZ+1oaSQPRF4MqsmHV 76 | 4ojXW5TeRNw0S8Ogbf3x0HTrEwIDAQABo4GjMIGgMA4GA1UdDwEB/wQEAwIFoDAd 77 | BgNVHSUEFjAUBggrBgEFBQcDAQYIKwYBBQUHAwIwDAYDVR0TAQH/BAIwADAdBgNV 78 | HQ4EFgQUfWQn1UGpENf3nNNzo2B+2SJBvFowHwYDVR0jBBgwFoAUwJ7TbCOeTnd2 79 | 4R9a31dcDsTM+V0wIQYDVR0RAQH/BBcwFYITci5mb3J3YXJkZG9tYWluLm5ldDAN 80 | BgkqhkiG9w0BAQsFAAOCAQEAxhkKy9meD5MmvuOU0E0uFFNwmGM5u24lnuZ/RcAM 81 | Xp9jhyIfWamTs4xH7EXzMsForLAudQNwvdi7ewUgXnDRRAuBy79tRN4YwsxzYUBu 82 | sKVpB+c99uAcEufdMjUUQqgDhtVWcVesrvx65EM4/r2YbseDduZEAIyvlZAxNNo/ 83 | 1Ox2m/V7yYmlnJ4WKPF5JrkKXLIfVc/puKwk16tEK6SM78hzS5HTUfksBzMauk32 84 | 9mvKLjflTx3sQseVqDX9m3l7pLagM15nL8IoBw+M4Hqx/E21CG89okL1PrmBDM+K 85 | izrL6bydOGcPEbqwRk5V0rm7w6qBYHN6OWAypSQ0UYYG+w== 86 | -----END CERTIFICATE----- 87 | -----BEGIN CERTIFICATE----- 88 | MIIDUDCCAjigAwIBAgIIVd1bNP8Jx4IwDQYJKoZIhvcNAQELBQAwIDEeMBwGA1UE 89 | AxMVUGViYmxlIFJvb3QgQ0EgMzJiNzJkMCAXDTI0MDgwMzA2MjAyMFoYDzIwNTQw 90 | ODAzMDYyMDIwWjAoMSYwJAYDVQQDEx1QZWJibGUgSW50ZXJtZWRpYXRlIENBIDY2 91 | NGEyYzCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAM4MmkMcMy1HmfaB 92 | uOGsnI2CA2m2sVm/QXi0mfZeffmaH8kz3zfqKzjm9Y19+4dHOOjwm8wrgxqKRLjQ 93 | SEnF30Lhb1RmnfX9loe2vkP66wAxhW3/Up+zBxSUJa5LsnzTy229aY1Y8Mbb6ENC 94 | bYIC0ZxBCwo/szvdxnaOPzAxeoV1/UsWkNbp0/Huu7WpnqhoPTrjRHNCU1xIv1ZE 95 | rKTig5NHwTqjzeqt3vT21inUmmwmEl5+HQFYWBpZyIKhfC0JaK4mjd3efMnUcqG8 96 | Ws1NvmSWkBc9JsL4JmWGEjv7aX6roCxrpOfFhWtog7BDcZ+EM25DCgxM5WxUGdrc 97 | VN1kZ8ECAwEAAaOBgzCBgDAOBgNVHQ8BAf8EBAMCAoQwHQYDVR0lBBYwFAYIKwYB 98 | BQUHAwEGCCsGAQUFBwMCMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFMCe02wj 99 | nk53duEfWt9XXA7EzPldMB8GA1UdIwQYMBaAFPkmTsD1ef0Otc7VLA4MxnJK12fP 100 | MA0GCSqGSIb3DQEBCwUAA4IBAQAFNlkf6EdNFcfwuSANT6OM9g72Yt0jRZxzlWO2 101 | bCO+TqSjoF3TKNpTwgi+aXQ+/mjKSrv8y8jRw/QM4brLpFDJ+L4KNEt6KNEeIL2F 102 | 87fuUihlTGjD34KKAwI+OfgfIaNpkcshlLs+V4hWXoROVdioLAj+rOExDjuriu/L 103 | 5v7Ke4iqIIoeoiWTjbmqxpOyYmfWm+kfo7vbR8uSjK2LQx7uWqgRCFAhwPbEFuEy 104 | JHTqTbvIWRYsjyFiMEIUh10sLqqMYcZoJBgmX//KH8Iw0PDEam+RGhod1Rb46Sm+ 105 | 7rOGV9tVAr/t6UsRAImu/Rrpst817EhR6vv07GiBDAH65XdZ 106 | -----END CERTIFICATE----- 107 | ` 108 | 109 | let certs = pemToDer(cert); 110 | let newCert = derToPem(certs, "certificate"); 111 | 112 | expect(newCert).toEqual(cert); 113 | expect(getCertExpiry(cert)).toEqual(1880432606000); 114 | expect(getCertExpiry(newCert)).toEqual(1880432606000); 115 | }) 116 | 117 | test('pem der conversion for key works', async () => { 118 | let key = `-----BEGIN PRIVATE KEY----- 119 | MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDFyWzaECHWuDLt 120 | x+OSUp4i+yPmGZSEpY9Qb/0IERFaJOUlLTi8l1Zbr/Dx/fwI39+21aVZVh4JqD6E 121 | ZWZKFSMFSe5ivN4I7aurV2SCqGtNb1sWkCGSa1XGJ5Kte5Sa6Ab5Dsxb8rDJDHPl 122 | 9OFs0zwWOf3t56tmDNIsOP3QGBhUHsGbkWap+mgMlEEprIBbnCvMOCbBSRuy7yOL 123 | UPH01OOgSGXf7uyNMLFVUlgc91v8hI9wTltFxUtP+gDnFe0v5/if78vJ2dz3DBwH 124 | hxSjZfZjXsK13EYuSfZclyOHUJrSIxn7WhpJA9EXgyqyYdXiiNdblN5E3DRLw6Bt 125 | /fHQdOsTAgMBAAECggEAJsUdk78uyuavgQG6P6/3NJczCcNA5CGJ7rQNDvw9gQST 126 | cE6lfP5TXMSnv9/P/DNaKH5Hm7PwTmdO3ef8fZAYHczIsE0iXvCrwnnuh1gZNIQc 127 | AFe/ZPKqTR3ruBrt3dGWsFJwx6NSeQ56V3zBhXIAqMC0YGKVq/reZfHD+vsGJdLL 128 | PVabyGQugZWJrqvhJMv8Nmj0nvg9ostL+qA1MKAZRNc5GURL4w6f147IE5Qbebhx 129 | daulsXnCfcP86OIPV1rDu8btj58exLLIsCyNtwDe0X/LHcKx6m5EX1yjBN7zssCL 130 | 0uwuc5MYYrwEg7PoiNL7y2PKmjD3roFqoWpH72W9iQKBgQDtFP53gJF9wjQrUIw1 131 | YY6NS9EVs6zwRyXjYSMOxm6bSNSdfwzRASpBTtaGMTFWMlPH0ADJ48oWeC86NbqA 132 | HNs85vAjlWfLF1jbGGkww4L4isxh3ie67XDnyTWEuZxDA1tsX17qoeYQJapxkUaR 133 | 4cNA6BQGikJ9aunELClJTwAKSQKBgQDVkbn63oXISeqG7uLHsK/ozcIa8t60roPb 134 | dk8XKm2eOIjlsTGOUB9eebZsptSPCN/cMvzscIJqd7YoVKeOFhEQ17QxxiwtCez/ 135 | A61fAZAJWMimGZRoPO2vmSFYoNkWj4jss2LVBuMq1VyrObVrvnZc9mp+eTdxo/C5 136 | fy36C8AqewKBgA/OE3zB/HEGzlWI5B/25frzb/fjZ4cJJzR2WFD2147QlyP8wUz5 137 | p+h8qf5+LwzRBBbQ/gx3fBRtZLCbvlgmFFOGDcJBho7aepj4kqKmlgedsSxhFAL5 138 | K0q4djHn8cvh4GlkHj7EFkNDT46Moci95TdhgVxCQVZ9FyJ10zbI5nbJAoGBAJnM 139 | 5D5B2d4vPPIHPtHH8CabZtm5ZaCAvPxi6von1+FFnXCsdp+iG7URucntKs4G+g+9 140 | uF8ddw3tQAUzUacFRSz36hCeQln89+t+XnA4092nTngvm6yllBYNFPKagzu4CkdL 141 | uDTpTNcf6Ch22qvI8bxoyLBj4wW3pjgv2pBjvfPZAoGBAOzrNQ6lWJow1rG8sQAF 142 | bfoxVpX6y9JJArJuT7bQO0TZOtPTTwjo94EyPUMpOlRLHko8r5buEl9EPW/mCOrX 143 | gLIo1xC6QTia+mZ/aK+vHuFhuGy37az8nJzR/1Ud61C7og+BoXEsJZi7+J0M/6Nr 144 | vV1rSGF5+dMeo8CldZlyMHDi 145 | -----END PRIVATE KEY----- 146 | ` 147 | 148 | let keys = pemToDer(key); 149 | let newKey = derToPem(keys, "private"); 150 | 151 | expect(newKey).toEqual(key); 152 | 153 | }) 154 | --------------------------------------------------------------------------------