diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 9860702e0..6ec6a7c91 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -6,15 +6,30 @@ labels: bug assignees: '' --- + **Checklist** - Have you pulled and found the error with `jc21/nginx-proxy-manager:latest` docker image? + - Yes / No - Are you sure you're not using someone else's docker image? -- If having problems with Lets Encrypt, have you made absolutely sure your site is accessible from outside of your network? + - Yes / No +- Have you searched for similar issues (both open and closed)? + - Yes / No **Describe the bug** -- A clear and concise description of what the bug is. -- What version of Nginx Proxy Manager is reported on the login page? + + + +**Nginx Proxy Manager Version** + + **To Reproduce** Steps to reproduce the behavior: @@ -23,14 +38,18 @@ Steps to reproduce the behavior: 3. Scroll down to '....' 4. See error + **Expected behavior** -A clear and concise description of what you expected to happen. + + **Screenshots** -If applicable, add screenshots to help explain your problem. + + **Operating System** -- Please specify if using a Rpi, Mac, orchestration tool or any other setups that might affect the reproduction of this error. + + **Additional context** -Add any other context about the problem here, docker version, browser version if applicable to the problem. Too much info is better than too little. + diff --git a/.github/ISSUE_TEMPLATE/dns_challenge_request.md b/.github/ISSUE_TEMPLATE/dns_challenge_request.md new file mode 100644 index 000000000..0a00f00f8 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/dns_challenge_request.md @@ -0,0 +1,18 @@ +--- +name: DNS challenge provider request +about: Suggest a new provider to be available for a certificate DNS challenge +title: '' +labels: dns provider request +assignees: '' + +--- + +**What provider would you like to see added to NPM?** + + + +**Have you checked if a certbot plugin exists?** + diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index 11fc491ef..cf5b0f772 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -7,14 +7,26 @@ assignees: '' --- + + **Is your feature request related to a problem? Please describe.** -A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + + **Describe the solution you'd like** -A clear and concise description of what you want to happen. + + **Describe alternatives you've considered** -A clear and concise description of any alternative solutions or features you've considered. + + **Additional context** -Add any other context or screenshots about the feature request here. + diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml new file mode 100644 index 000000000..f859b1278 --- /dev/null +++ b/.github/workflows/stale.yml @@ -0,0 +1,21 @@ +name: 'Close stale issues and PRs' +on: + schedule: + - cron: '30 1 * * *' + workflow_dispatch: + +jobs: + stale: + runs-on: ubuntu-latest + steps: + - uses: actions/stale@v9 + with: + stale-issue-label: 'stale' + stale-pr-label: 'stale' + stale-issue-message: 'Issue is now considered stale. If you want to keep it open, please comment :+1:' + stale-pr-message: 'PR is now considered stale. If you want to keep it open, please comment :+1:' + close-issue-message: 'Issue was closed due to inactivity.' + close-pr-message: 'PR was closed due to inactivity.' + days-before-stale: 182 + days-before-close: 365 + operations-per-run: 50 diff --git a/.gitignore b/.gitignore index deb3fb55c..fbb8167e7 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,8 @@ .idea ._* .vscode - +certbot-help.txt +test/node_modules +*/node_modules +docker/dev/dnsrouter-config.json.tmp +docker/dev/resolv.conf diff --git a/.jenkins/config.json b/.jenkins/config.json deleted file mode 100644 index 19ad2237f..000000000 --- a/.jenkins/config.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "database": { - "engine": "mysql", - "host": "db", - "name": "npm", - "user": "npm", - "password": "npm", - "port": 3306 - } -} \ No newline at end of file diff --git a/.version b/.version index 7c3272873..e4643748f 100644 --- a/.version +++ b/.version @@ -1 +1 @@ -2.1.1 \ No newline at end of file +2.12.6 diff --git a/Jenkinsfile b/Jenkinsfile index 322f22f7f..af913c2e0 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -1,3 +1,9 @@ +import groovy.transform.Field + +@Field +def shOutput = "" +def buildxPushTags = "" + pipeline { agent { label 'docker-multiarch' @@ -5,16 +11,15 @@ pipeline { options { buildDiscarder(logRotator(numToKeepStr: '5')) disableConcurrentBuilds() + ansiColor('xterm') } environment { - IMAGE = "nginx-proxy-manager" + IMAGE = 'nginx-proxy-manager' BUILD_VERSION = getVersion() - MAJOR_VERSION = "2" - BRANCH_LOWER = "${BRANCH_NAME.toLowerCase().replaceAll('/', '-')}" - COMPOSE_PROJECT_NAME = "npm_${BRANCH_LOWER}_${BUILD_NUMBER}" - COMPOSE_FILE = 'docker/docker-compose.ci.yml' + MAJOR_VERSION = '2' + BRANCH_LOWER = "${BRANCH_NAME.toLowerCase().replaceAll('\\\\', '-').replaceAll('/', '-').replaceAll('\\.', '-')}" + BUILDX_NAME = "npm_${BRANCH_LOWER}_${BUILD_NUMBER}" COMPOSE_INTERACTIVE_NO_CLI = 1 - BUILDX_NAME = "${COMPOSE_PROJECT_NAME}" } stages { stage('Environment') { @@ -25,7 +30,7 @@ pipeline { } steps { script { - env.BUILDX_PUSH_TAGS = "-t docker.io/jc21/${IMAGE}:${BUILD_VERSION} -t docker.io/jc21/${IMAGE}:${MAJOR_VERSION} -t docker.io/jc21/${IMAGE}:latest" + buildxPushTags = "-t docker.io/jc21/${IMAGE}:${BUILD_VERSION} -t docker.io/jc21/${IMAGE}:${MAJOR_VERSION} -t docker.io/jc21/${IMAGE}:latest" } } } @@ -38,101 +43,217 @@ pipeline { steps { script { // Defaults to the Branch name, which is applies to all branches AND pr's - env.BUILDX_PUSH_TAGS = "-t docker.io/jc21/${IMAGE}:github-${BRANCH_LOWER}" + buildxPushTags = "-t docker.io/nginxproxymanager/${IMAGE}-dev:${BRANCH_LOWER}" + } + } + } + stage('Versions') { + steps { + sh 'cat frontend/package.json | jq --arg BUILD_VERSION "${BUILD_VERSION}" \'.version = $BUILD_VERSION\' | sponge frontend/package.json' + sh 'echo -e "\\E[1;36mFrontend Version is:\\E[1;33m $(cat frontend/package.json | jq -r .version)\\E[0m"' + sh 'cat backend/package.json | jq --arg BUILD_VERSION "${BUILD_VERSION}" \'.version = $BUILD_VERSION\' | sponge backend/package.json' + sh 'echo -e "\\E[1;36mBackend Version is:\\E[1;33m $(cat backend/package.json | jq -r .version)\\E[0m"' + sh 'sed -i -E "s/(version-)[0-9]+\\.[0-9]+\\.[0-9]+(-green)/\\1${BUILD_VERSION}\\2/" README.md' + } + } + stage('Docker Login') { + steps { + withCredentials([usernamePassword(credentialsId: 'jc21-dockerhub', passwordVariable: 'dpass', usernameVariable: 'duser')]) { + sh 'docker login -u "${duser}" -p "${dpass}"' } } } } } - stage('Frontend') { - steps { - ansiColor('xterm') { - sh './scripts/frontend-build' + stage('Builds') { + parallel { + stage('Project') { + steps { + script { + // Frontend and Backend + def shStatusCode = sh(label: 'Checking and Building', returnStatus: true, script: ''' + set -e + ./scripts/ci/frontend-build > ${WORKSPACE}/tmp-sh-build 2>&1 + ./scripts/ci/test-and-build > ${WORKSPACE}/tmp-sh-build 2>&1 + ''') + shOutput = readFile "${env.WORKSPACE}/tmp-sh-build" + if (shStatusCode != 0) { + error "${shOutput}" + } + } + } + post { + always { + sh 'rm -f ${WORKSPACE}/tmp-sh-build' + } + failure { + npmGithubPrComment("CI Error:\n\n```\n${shOutput}\n```", true) + } + } + } + stage('Docs') { + steps { + dir(path: 'docs') { + sh 'yarn install' + sh 'yarn build' + } + } } } } - stage('Backend') { + stage('Test Sqlite') { + environment { + COMPOSE_PROJECT_NAME = "npm_${BRANCH_LOWER}_${BUILD_NUMBER}_sqlite" + COMPOSE_FILE = 'docker/docker-compose.ci.yml:docker/docker-compose.ci.sqlite.yml' + } + when { + not { + equals expected: 'UNSTABLE', actual: currentBuild.result + } + } steps { - ansiColor('xterm') { - echo 'Checking Syntax ...' - // See: https://github.com/yarnpkg/yarn/issues/3254 - sh '''docker run --rm \\ - -v "$(pwd)/backend:/app" \\ - -w /app \\ - node:latest \\ - sh -c "yarn install && yarn eslint . && rm -rf node_modules" - ''' - - echo 'Docker Build ...' - sh '''docker build --pull --no-cache --squash --compress \\ - -t "${IMAGE}:ci-${BUILD_NUMBER}" \\ - -f docker/Dockerfile \\ - --build-arg TARGETPLATFORM=linux/amd64 \\ - --build-arg BUILDPLATFORM=linux/amd64 \\ - --build-arg BUILD_VERSION="${BUILD_VERSION}" \\ - --build-arg BUILD_COMMIT="${BUILD_COMMIT}" \\ - --build-arg BUILD_DATE="$(date '+%Y-%m-%d %T %Z')" \\ - . - ''' + sh 'rm -rf ./test/results/junit/*' + sh './scripts/ci/fulltest-cypress' + } + post { + always { + // Dumps to analyze later + sh 'mkdir -p debug/sqlite' + sh 'docker logs $(docker-compose ps --all -q fullstack) > debug/sqlite/docker_fullstack.log 2>&1' + sh 'docker logs $(docker-compose ps --all -q stepca) > debug/sqlite/docker_stepca.log 2>&1' + sh 'docker logs $(docker-compose ps --all -q pdns) > debug/sqlite/docker_pdns.log 2>&1' + sh 'docker logs $(docker-compose ps --all -q pdns-db) > debug/sqlite/docker_pdns-db.log 2>&1' + sh 'docker logs $(docker-compose ps --all -q dnsrouter) > debug/sqlite/docker_dnsrouter.log 2>&1' + junit 'test/results/junit/*' + sh 'docker-compose down --remove-orphans --volumes -t 30 || true' + } + unstable { + dir(path: 'test/results') { + archiveArtifacts(allowEmptyArchive: true, artifacts: '**/*', excludes: '**/*.xml') + } } } } - stage('Test') { - steps { - ansiColor('xterm') { - // Bring up a stack - sh 'docker-compose up -d fullstack' - sh './scripts/wait-healthy $(docker-compose ps -q fullstack) 120' - - // Run tests - sh 'rm -rf test/results' - sh 'docker-compose up cypress' - // Get results - sh 'docker cp -L "$(docker-compose ps -q cypress):/results" test/' + stage('Test Mysql') { + environment { + COMPOSE_PROJECT_NAME = "npm_${BRANCH_LOWER}_${BUILD_NUMBER}_mysql" + COMPOSE_FILE = 'docker/docker-compose.ci.yml:docker/docker-compose.ci.mysql.yml' + } + when { + not { + equals expected: 'UNSTABLE', actual: currentBuild.result } } + steps { + sh 'rm -rf ./test/results/junit/*' + sh './scripts/ci/fulltest-cypress' + } post { always { + // Dumps to analyze later + sh 'mkdir -p debug/mysql' + sh 'docker logs $(docker-compose ps --all -q fullstack) > debug/mysql/docker_fullstack.log 2>&1' + sh 'docker logs $(docker-compose ps --all -q stepca) > debug/mysql/docker_stepca.log 2>&1' + sh 'docker logs $(docker-compose ps --all -q pdns) > debug/mysql/docker_pdns.log 2>&1' + sh 'docker logs $(docker-compose ps --all -q pdns-db) > debug/mysql/docker_pdns-db.log 2>&1' + sh 'docker logs $(docker-compose ps --all -q dnsrouter) > debug/mysql/docker_dnsrouter.log 2>&1' junit 'test/results/junit/*' - // Cypress videos and screenshot artifacts + sh 'docker-compose down --remove-orphans --volumes -t 30 || true' + } + unstable { dir(path: 'test/results') { - archiveArtifacts allowEmptyArchive: true, artifacts: '**/*', excludes: '**/*.xml' + archiveArtifacts(allowEmptyArchive: true, artifacts: '**/*', excludes: '**/*.xml') } - // Dumps to analyze later - sh 'mkdir -p debug' - sh 'docker-compose logs fullstack | gzip > debug/docker_fullstack.log.gz' } } } - stage('MultiArch Build') { + stage('Test Postgres') { + environment { + COMPOSE_PROJECT_NAME = "npm_${BRANCH_LOWER}_${BUILD_NUMBER}_postgres" + COMPOSE_FILE = 'docker/docker-compose.ci.yml:docker/docker-compose.ci.postgres.yml' + } when { not { equals expected: 'UNSTABLE', actual: currentBuild.result } } steps { - ansiColor('xterm') { - withCredentials([usernamePassword(credentialsId: 'jc21-dockerhub', passwordVariable: 'dpass', usernameVariable: 'duser')]) { - sh "docker login -u '${duser}' -p '${dpass}'" - // Buildx with push - sh "./scripts/buildx --push ${BUILDX_PUSH_TAGS}" + sh 'rm -rf ./test/results/junit/*' + sh './scripts/ci/fulltest-cypress' + } + post { + always { + // Dumps to analyze later + sh 'mkdir -p debug/postgres' + sh 'docker logs $(docker-compose ps --all -q fullstack) > debug/postgres/docker_fullstack.log 2>&1' + sh 'docker logs $(docker-compose ps --all -q stepca) > debug/postgres/docker_stepca.log 2>&1' + sh 'docker logs $(docker-compose ps --all -q pdns) > debug/postgres/docker_pdns.log 2>&1' + sh 'docker logs $(docker-compose ps --all -q pdns-db) > debug/postgres/docker_pdns-db.log 2>&1' + sh 'docker logs $(docker-compose ps --all -q dnsrouter) > debug/postgres/docker_dnsrouter.log 2>&1' + sh 'docker logs $(docker-compose ps --all -q db-postgres) > debug/postgres/docker_db-postgres.log 2>&1' + sh 'docker logs $(docker-compose ps --all -q authentik) > debug/postgres/docker_authentik.log 2>&1' + sh 'docker logs $(docker-compose ps --all -q authentik-redis) > debug/postgres/docker_authentik-redis.log 2>&1' + sh 'docker logs $(docker-compose ps --all -q authentik-ldap) > debug/postgres/docker_authentik-ldap.log 2>&1' + + junit 'test/results/junit/*' + sh 'docker-compose down --remove-orphans --volumes -t 30 || true' + } + unstable { + dir(path: 'test/results') { + archiveArtifacts(allowEmptyArchive: true, artifacts: '**/*', excludes: '**/*.xml') } } } } - stage('PR Comment') { + stage('MultiArch Build') { when { - allOf { - changeRequest() - not { - equals expected: 'UNSTABLE', actual: currentBuild.result - } + not { + equals expected: 'UNSTABLE', actual: currentBuild.result } } steps { - ansiColor('xterm') { - script { - def comment = pullRequest.comment("Docker Image for build ${BUILD_NUMBER} is available on [DockerHub](https://cloud.docker.com/repository/docker/jc21/${IMAGE}) as `jc21/${IMAGE}:github-${BRANCH_LOWER}`") + sh "./scripts/buildx --push ${buildxPushTags}" + } + } + stage('Docs / Comment') { + parallel { + stage('Docs Job') { + when { + allOf { + branch pattern: "^(develop|master)\$", comparator: "REGEXP" + not { + equals expected: 'UNSTABLE', actual: currentBuild.result + } + } + } + steps { + build wait: false, job: 'nginx-proxy-manager-docs', parameters: [string(name: 'docs_branch', value: "$BRANCH_NAME")] + } + } + stage('PR Comment') { + when { + allOf { + changeRequest() + not { + equals expected: 'UNSTABLE', actual: currentBuild.result + } + } + } + steps { + script { + npmGithubPrComment("""Docker Image for build ${BUILD_NUMBER} is available on [DockerHub](https://cloud.docker.com/repository/docker/nginxproxymanager/${IMAGE}-dev): +``` +nginxproxymanager/${IMAGE}-dev:${BRANCH_LOWER} +``` + +> [!NOTE] +> Ensure you backup your NPM instance before testing this image! Especially if there are database changes. +> This is a different docker image namespace than the official image. + +> [!WARNING] +> Changes and additions to DNS Providers require verification by at least 2 members of the community! +""", true) + } } } } @@ -140,22 +261,15 @@ pipeline { } post { always { - sh 'docker-compose down --rmi all --remove-orphans --volumes -t 30' sh 'echo Reverting ownership' - sh 'docker run --rm -v $(pwd):/data ${DOCKER_CI_TOOLS} chown -R $(id -u):$(id -g) /data' - } - success { - juxtapose event: 'success' - sh 'figlet "SUCCESS"' + sh 'docker run --rm -v "$(pwd):/data" jc21/ci-tools chown -R "$(id -u):$(id -g)" /data' + printResult(true) } failure { - juxtapose event: 'failure' - sh 'figlet "FAILURE"' + archiveArtifacts(artifacts: 'debug/**/*.*', allowEmptyArchive: true) } unstable { - archiveArtifacts(artifacts: 'debug/**.*', allowEmptyArchive: true) - juxtapose event: 'unstable' - sh 'figlet "UNSTABLE"' + archiveArtifacts(artifacts: 'debug/**/*.*', allowEmptyArchive: true) } } } diff --git a/README.md b/README.md index 680304f9f..2116a55ae 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,25 @@ - - -# Nginx Proxy Manager - - - - - -[](https://ci.nginxproxymanager.jc21.com/job/nginx-proxy-manager/job/master/) +
This project comes as a pre-built docker image that enables you to easily forward to your websites running at home or otherwise, including free SSL, without having to know too much about Nginx or Letsencrypt. +- [Quick Setup](#quick-setup) +- [Full Setup](https://nginxproxymanager.com/setup/) +- [Screenshots](https://nginxproxymanager.com/screenshots/) ## Project Goal -I created this project to fill a personal need to provide users with a easy way to accomplish reverse +I created this project to fill a personal need to provide users with an easy way to accomplish reverse proxying hosts with SSL termination and it had to be so easy that a monkey could do it. This goal hasn't changed. While there might be advanced options they are optional and the project should be as simple as possible so that the barrier for entry here is low. @@ -32,46 +37,58 @@ so that the barrier for entry here is low. - User management, permissions and audit log -## Screenshots +## Hosting your home network -[](https://public.jc21.com/nginx-proxy-manager/v2/large/login.jpg) -[](https://public.jc21.com/nginx-proxy-manager/v2/large/dashboard.jpg) -[](https://public.jc21.com/nginx-proxy-manager/v2/large/proxy-hosts.jpg) -[](https://public.jc21.com/nginx-proxy-manager/v2/large/proxy-hosts-new1.jpg) -[](https://public.jc21.com/nginx-proxy-manager/v2/large/proxy-hosts-new2.jpg) -[](https://public.jc21.com/nginx-proxy-manager/v2/large/redirection-hosts.jpg) -[](https://public.jc21.com/nginx-proxy-manager/v2/large/redirection-hosts-new1.jpg) -[](https://public.jc21.com/nginx-proxy-manager/v2/large/streams.jpg) -[](https://public.jc21.com/nginx-proxy-manager/v2/large/streams-new1.jpg) -[](https://public.jc21.com/nginx-proxy-manager/v2/large/dead-hosts.jpg) -[](https://public.jc21.com/nginx-proxy-manager/v2/large/dead-hosts-new1.jpg) -[](https://public.jc21.com/nginx-proxy-manager/v2/large/certificates.jpg) -[](https://public.jc21.com/nginx-proxy-manager/v2/large/certificates-new1.jpg) -[](https://public.jc21.com/nginx-proxy-manager/v2/large/certificates-new2.jpg) -[](https://public.jc21.com/nginx-proxy-manager/v2/large/access-lists.jpg) -[](https://public.jc21.com/nginx-proxy-manager/v2/large/access-lists-new1.jpg) -[](https://public.jc21.com/nginx-proxy-manager/v2/large/users.jpg) -[](https://public.jc21.com/nginx-proxy-manager/v2/large/users-permissions.jpg) -[](https://public.jc21.com/nginx-proxy-manager/v2/large/audit-log.jpg) +I won't go in to too much detail here but here are the basics for someone new to this self-hosted world. +1. Your home router will have a Port Forwarding section somewhere. Log in and find it +2. Add port forwarding for port 80 and 443 to the server hosting this project +3. Configure your domain name details to point to your home, either with a static ip or a service like DuckDNS or [Amazon Route53](https://github.com/jc21/route53-ddns) +4. Use the Nginx Proxy Manager as your gateway to forward to your other web based services -## Getting started +## Quick Setup -Please consult the [installation instructions](doc/INSTALL.md) for a complete guide or -if you just want to get up and running in the quickest time possible, grab all the files in the `doc/example/` folder and run `docker-compose up -d` +1. Install Docker and Docker-Compose +- [Docker Install documentation](https://docs.docker.com/install/) +- [Docker-Compose Install documentation](https://docs.docker.com/compose/install/) -## Administration +2. Create a docker-compose.yml file similar to this: -When your docker container is running, connect to it on port `81` for the admin interface. +```yml +services: + app: + image: 'docker.io/jc21/nginx-proxy-manager:latest' + restart: unless-stopped + ports: + - '80:80' + - '81:81' + - '443:443' + volumes: + - ./data:/data + - ./letsencrypt:/etc/letsencrypt +``` -[http://localhost:81](http://localhost:81) +This is the bare minimum configuration required. See the [documentation](https://nginxproxymanager.com/setup/) for more. -Note: Requesting SSL Certificates won't work until this project is accessible from the outside world, as explained below. +3. Bring up your stack by running +```bash +docker-compose up -d -### Default Administrator User +# If using docker-compose-plugin +docker compose up -d +``` + +4. Log in to the Admin UI + +When your docker container is running, connect to it on port `81` for the admin interface. +Sometimes this can take a little bit because of the entropy of keys. + +[http://127.0.0.1:81](http://127.0.0.1:81) + +Default Admin User: ``` Email: admin@example.com Password: changeme @@ -80,22 +97,24 @@ Password: changeme Immediately after logging in with this default user you will be asked to modify your details and change your password. -## Hosting your home network +## Contributing -I won't go in to too much detail here but here are the basics for someone new to this self-hosted world. +All are welcome to create pull requests for this project, against the `develop` branch. Official releases are created from the `master` branch. + +CI is used in this project. All PR's must pass before being considered. After passing, +docker builds for PR's are available on dockerhub for manual verifications. + +Documentation within the `develop` branch is available for preview at +[https://develop.nginxproxymanager.com](https://develop.nginxproxymanager.com) -1. Your home router will have a Port Forwarding section somewhere. Log in and find it -2. Add port forwarding for port 80 and 443 to the server hosting this project -3. Configure your domain name details to point to your home, either with a static ip or a service like DuckDNS or [Amazon Route53](https://github.com/jc21/route53-ddns) -4. Use the Nginx Proxy Manager as your gateway to forward to your other web based services +### Contributors -## Nginx Proxy Manager in the wild +Special thanks to [all of our contributors](https://github.com/NginxProxyManager/nginx-proxy-manager/graphs/contributors). -As this software gains popularity it's common to see it integrated with other platforms. Please be aware that unless specifically mentioned in the documenation of those -integrations, they are *not supported* by me and any donation links on the pages of those integrations will not come to me even though it looks like it. -Known integrations: +## Getting Support -- [HomeAssistant Hass.io plugin](https://github.com/hassio-addons/addon-nginx-proxy-manager) -- [UnRaid / Synology](https://github.com/jlesage/docker-nginx-proxy-manager) +1. [Found a bug?](https://github.com/NginxProxyManager/nginx-proxy-manager/issues) +2. [Discussions](https://github.com/NginxProxyManager/nginx-proxy-manager/discussions) +3. [Reddit](https://reddit.com/r/nginxproxymanager) diff --git a/backend/.gitignore b/backend/.gitignore index 963bfd80b..149080b91 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -4,3 +4,5 @@ yarn-error.log tmp certbot.log node_modules +core.* + diff --git a/backend/app.js b/backend/app.js index fc39e105c..59f7def20 100644 --- a/backend/app.js +++ b/backend/app.js @@ -2,6 +2,7 @@ const express = require('express'); const bodyParser = require('body-parser'); const fileUpload = require('express-fileupload'); const compression = require('compression'); +const config = require('./lib/config'); const log = require('./logger').express; /** @@ -24,7 +25,7 @@ app.enable('trust proxy', ['loopback', 'linklocal', 'uniquelocal']); app.enable('strict routing'); // pretty print JSON when not live -if (process.env.NODE_ENV !== 'production') { +if (config.debug()) { app.set('json spaces', 2); } @@ -40,19 +41,18 @@ app.use(function (req, res, next) { } res.set({ - 'Strict-Transport-Security': 'includeSubDomains; max-age=631138519; preload', - 'X-XSS-Protection': '1; mode=block', - 'X-Content-Type-Options': 'nosniff', - 'X-Frame-Options': x_frame_options, - 'Cache-Control': 'no-cache, no-store, max-age=0, must-revalidate', - Pragma: 'no-cache', - Expires: 0 + 'X-XSS-Protection': '1; mode=block', + 'X-Content-Type-Options': 'nosniff', + 'X-Frame-Options': x_frame_options, + 'Cache-Control': 'no-cache, no-store, max-age=0, must-revalidate', + Pragma: 'no-cache', + Expires: 0 }); next(); }); app.use(require('./lib/express/jwt')()); -app.use('/', require('./routes/api/main')); +app.use('/', require('./routes/main')); // production error handler // no stacktraces leaked to user @@ -66,7 +66,7 @@ app.use(function (err, req, res, next) { } }; - if (process.env.NODE_ENV === 'development') { + if (config.debug() || (req.baseUrl + req.path).includes('nginx/certificates')) { payload.debug = { stack: typeof err.stack !== 'undefined' && err.stack ? err.stack.split('\n') : null, previous: err.previous @@ -75,7 +75,7 @@ app.use(function (err, req, res, next) { // Not every error is worth logging - but this is good for now until it gets annoying. if (typeof err.stack !== 'undefined' && err.stack) { - if (process.env.NODE_ENV === 'development') { + if (config.debug()) { log.debug(err.stack); } else if (typeof err.public == 'undefined' || !err.public) { log.warn(err.message); diff --git a/backend/config/default.json b/backend/config/default.json index 64ab577c8..154e66e48 100644 --- a/backend/config/default.json +++ b/backend/config/default.json @@ -1,6 +1,6 @@ { "database": { - "engine": "mysql", + "engine": "mysql2", "host": "db", "name": "npm", "user": "npm", diff --git a/backend/config/sqlite-test-db.json b/backend/config/sqlite-test-db.json new file mode 100644 index 000000000..ad5488651 --- /dev/null +++ b/backend/config/sqlite-test-db.json @@ -0,0 +1,26 @@ +{ + "database": { + "engine": "knex-native", + "knex": { + "client": "sqlite3", + "connection": { + "filename": "/app/config/mydb.sqlite" + }, + "pool": { + "min": 0, + "max": 1, + "createTimeoutMillis": 3000, + "acquireTimeoutMillis": 30000, + "idleTimeoutMillis": 30000, + "reapIntervalMillis": 1000, + "createRetryIntervalMillis": 100, + "propagateCreateError": false + }, + "migrations": { + "tableName": "migrations", + "stub": "src/backend/lib/migrate_template.js", + "directory": "src/backend/migrations" + } + } + } +} diff --git a/backend/db.js b/backend/db.js index 1f4d5b021..1a8b1634e 100644 --- a/backend/db.js +++ b/backend/db.js @@ -1,25 +1,27 @@ -const config = require('config'); +const config = require('./lib/config'); if (!config.has('database')) { - throw new Error('Database config does not exist! Please read the instructions: https://github.com/jc21/nginx-proxy-manager/blob/master/doc/INSTALL.md'); + throw new Error('Database config does not exist! Please read the instructions: https://nginxproxymanager.com/setup/'); } -let data = { - client: config.database.engine, - connection: { - host: config.database.host, - user: config.database.user, - password: config.database.password, - database: config.database.name, - port: config.database.port - }, - migrations: { - tableName: 'migrations' +function generateDbConfig() { + const cfg = config.get('database'); + if (cfg.engine === 'knex-native') { + return cfg.knex; } -}; - -if (typeof config.database.version !== 'undefined') { - data.version = config.database.version; + return { + client: cfg.engine, + connection: { + host: cfg.host, + user: cfg.user, + password: cfg.password, + database: cfg.name, + port: cfg.port + }, + migrations: { + tableName: 'migrations' + } + }; } -module.exports = require('knex')(data); +module.exports = require('knex')(generateDbConfig()); diff --git a/backend/doc/api.swagger.json b/backend/doc/api.swagger.json deleted file mode 100644 index 06c025648..000000000 --- a/backend/doc/api.swagger.json +++ /dev/null @@ -1,1254 +0,0 @@ -{ - "openapi": "3.0.0", - "info": { - "title": "Nginx Proxy Manager API", - "version": "2.x.x" - }, - "servers": [ - { - "url": "http://127.0.0.1:81/api" - } - ], - "paths": { - "/": { - "get": { - "operationId": "health", - "summary": "Returns the API health status", - "responses": { - "200": { - "description": "200 response", - "content": { - "application/json": { - "examples": { - "default": { - "value": { - "status": "OK", - "version": { - "major": 2, - "minor": 1, - "revision": 0 - } - } - } - }, - "schema": { - "$ref": "#/components/schemas/HealthObject" - } - } - } - } - } - } - }, - "/schema": { - "get": { - "operationId": "schema", - "responses": { - "200": { - "description": "200 response" - } - }, - "summary": "Returns this swagger API schema" - } - }, - "/tokens": { - "get": { - "operationId": "refreshToken", - "summary": "Refresh your access token", - "tags": [ - "Tokens" - ], - "security": [ - { - "BearerAuth": [ - "tokens" - ] - } - ], - "responses": { - "200": { - "description": "200 response", - "content": { - "application/json": { - "examples": { - "default": { - "value": { - "expires": 1566540510, - "token": "eyJhbGciOiJSUzUxMiIsInR5cCI6IkpXVCJ9.ey...xaHKYr3Kk6MvkUjcC4" - } - } - }, - "schema": { - "$ref": "#/components/schemas/TokenObject" - } - } - } - } - } - }, - "post": { - "operationId": "requestToken", - "parameters": [ - { - "description": "Credentials Payload", - "in": "body", - "name": "credentials", - "required": true, - "schema": { - "additionalProperties": false, - "properties": { - "identity": { - "minLength": 1, - "type": "string" - }, - "scope": { - "minLength": 1, - "type": "string", - "enum": [ - "user" - ] - }, - "secret": { - "minLength": 1, - "type": "string" - } - }, - "required": [ - "identity", - "secret" - ], - "type": "object" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "examples": { - "default": { - "value": { - "result": { - "expires": 1566540510, - "token": "eyJhbGciOiJSUzUxMiIsInR5cCI6IkpXVCJ9.ey...xaHKYr3Kk6MvkUjcC4" - } - } - } - }, - "schema": { - "$ref": "#/components/schemas/TokenObject" - } - } - }, - "description": "200 response" - } - }, - "summary": "Request a new access token from credentials", - "tags": [ - "Tokens" - ] - } - }, - "/settings": { - "get": { - "operationId": "getSettings", - "summary": "Get all settings", - "tags": [ - "Settings" - ], - "security": [ - { - "BearerAuth": [ - "settings" - ] - } - ], - "responses": { - "200": { - "description": "200 response", - "content": { - "application/json": { - "examples": { - "default": { - "value": [ - { - "id": "default-site", - "name": "Default Site", - "description": "What to show when Nginx is hit with an unknown Host", - "value": "congratulations", - "meta": {} - } - ] - } - }, - "schema": { - "$ref": "#/components/schemas/SettingsList" - } - } - } - } - } - } - }, - "/settings/{settingID}": { - "get": { - "operationId": "getSetting", - "summary": "Get a setting", - "tags": [ - "Settings" - ], - "security": [ - { - "BearerAuth": [ - "settings" - ] - } - ], - "parameters": [ - { - "in": "path", - "name": "settingID", - "schema": { - "type": "string", - "minLength": 1 - }, - "required": true, - "description": "Setting ID", - "example": "default-site" - } - ], - "responses": { - "200": { - "description": "200 response", - "content": { - "application/json": { - "examples": { - "default": { - "value": { - "id": "default-site", - "name": "Default Site", - "description": "What to show when Nginx is hit with an unknown Host", - "value": "congratulations", - "meta": {} - } - } - }, - "schema": { - "$ref": "#/components/schemas/SettingObject" - } - } - } - } - } - }, - "put": { - "operationId": "updateSetting", - "summary": "Update a setting", - "tags": [ - "Settings" - ], - "security": [ - { - "BearerAuth": [ - "settings" - ] - } - ], - "parameters": [ - { - "in": "path", - "name": "settingID", - "schema": { - "type": "string", - "minLength": 1 - }, - "required": true, - "description": "Setting ID", - "example": "default-site" - }, - { - "in": "body", - "name": "setting", - "description": "Setting Payload", - "required": true, - "schema": { - "$ref": "#/components/schemas/SettingObject" - } - } - ], - "responses": { - "200": { - "description": "200 response", - "content": { - "application/json": { - "examples": { - "default": { - "value": { - "id": "default-site", - "name": "Default Site", - "description": "What to show when Nginx is hit with an unknown Host", - "value": "congratulations", - "meta": {} - } - } - }, - "schema": { - "$ref": "#/components/schemas/SettingObject" - } - } - } - } - } - } - }, - "/users": { - "get": { - "operationId": "getUsers", - "summary": "Get all users", - "tags": [ - "Users" - ], - "security": [ - { - "BearerAuth": [ - "users" - ] - } - ], - "parameters": [ - { - "in": "query", - "name": "expand", - "description": "Expansions", - "schema": { - "type": "string", - "enum": [ - "permissions" - ] - } - } - ], - "responses": { - "200": { - "description": "200 response", - "content": { - "application/json": { - "examples": { - "default": { - "value": [ - { - "id": 1, - "created_on": "2020-01-30T09:36:08.000Z", - "modified_on": "2020-01-30T09:41:04.000Z", - "is_disabled": 0, - "email": "jc@jc21.com", - "name": "Jamie Curnow", - "nickname": "James", - "avatar": "//www.gravatar.com/avatar/6193176330f8d38747f038c170ddb193?default=mm", - "roles": [ - "admin" - ] - } - ] - }, - "withPermissions": { - "value": [ - { - "id": 1, - "created_on": "2020-01-30T09:36:08.000Z", - "modified_on": "2020-01-30T09:41:04.000Z", - "is_disabled": 0, - "email": "jc@jc21.com", - "name": "Jamie Curnow", - "nickname": "James", - "avatar": "//www.gravatar.com/avatar/6193176330f8d38747f038c170ddb193?default=mm", - "roles": [ - "admin" - ], - "permissions": { - "visibility": "all", - "proxy_hosts": "manage", - "redirection_hosts": "manage", - "dead_hosts": "manage", - "streams": "manage", - "access_lists": "manage", - "certificates": "manage" - } - } - ] - } - }, - "schema": { - "$ref": "#/components/schemas/UsersList" - } - } - } - } - } - }, - "post": { - "operationId": "createUser", - "summary": "Create a User", - "tags": [ - "Users" - ], - "security": [ - { - "BearerAuth": [ - "users" - ] - } - ], - "parameters": [ - { - "in": "body", - "name": "user", - "description": "User Payload", - "required": true, - "schema": { - "$ref": "#/components/schemas/UserObject" - } - } - ], - "responses": { - "201": { - "description": "201 response", - "content": { - "application/json": { - "examples": { - "default": { - "value": { - "id": 2, - "created_on": "2020-01-30T09:36:08.000Z", - "modified_on": "2020-01-30T09:41:04.000Z", - "is_disabled": 0, - "email": "jc@jc21.com", - "name": "Jamie Curnow", - "nickname": "James", - "avatar": "//www.gravatar.com/avatar/6193176330f8d38747f038c170ddb193?default=mm", - "roles": [ - "admin" - ], - "permissions": { - "visibility": "all", - "proxy_hosts": "manage", - "redirection_hosts": "manage", - "dead_hosts": "manage", - "streams": "manage", - "access_lists": "manage", - "certificates": "manage" - } - } - } - }, - "schema": { - "$ref": "#/components/schemas/UserObject" - } - } - } - } - } - } - }, - "/users/{userID}": { - "get": { - "operationId": "getUser", - "summary": "Get a user", - "tags": [ - "Users" - ], - "security": [ - { - "BearerAuth": [ - "users" - ] - } - ], - "parameters": [ - { - "in": "path", - "name": "userID", - "schema": { - "oneOf": [ - { - "type": "string", - "pattern": "^me$" - }, - { - "type": "integer", - "minimum": 1 - } - ] - }, - "required": true, - "description": "User ID or 'me' for yourself", - "example": 1 - } - ], - "responses": { - "200": { - "description": "200 response", - "content": { - "application/json": { - "examples": { - "default": { - "value": { - "id": 1, - "created_on": "2020-01-30T09:36:08.000Z", - "modified_on": "2020-01-30T09:41:04.000Z", - "is_disabled": 0, - "email": "jc@jc21.com", - "name": "Jamie Curnow", - "nickname": "James", - "avatar": "//www.gravatar.com/avatar/6193176330f8d38747f038c170ddb193?default=mm", - "roles": [ - "admin" - ] - } - } - }, - "schema": { - "$ref": "#/components/schemas/UserObject" - } - } - } - } - } - }, - "put": { - "operationId": "updateUser", - "summary": "Update a User", - "tags": [ - "Users" - ], - "security": [ - { - "BearerAuth": [ - "users" - ] - } - ], - "parameters": [ - { - "in": "path", - "name": "userID", - "schema": { - "oneOf": [ - { - "type": "string", - "pattern": "^me$" - }, - { - "type": "integer", - "minimum": 1 - } - ] - }, - "required": true, - "description": "User ID or 'me' for yourself", - "example": 2 - }, - { - "in": "body", - "name": "user", - "description": "User Payload", - "required": true, - "schema": { - "$ref": "#/components/schemas/UserObject" - } - } - ], - "responses": { - "200": { - "description": "200 response", - "content": { - "application/json": { - "examples": { - "default": { - "value": { - "id": 2, - "created_on": "2020-01-30T09:36:08.000Z", - "modified_on": "2020-01-30T09:41:04.000Z", - "is_disabled": 0, - "email": "jc@jc21.com", - "name": "Jamie Curnow", - "nickname": "James", - "avatar": "//www.gravatar.com/avatar/6193176330f8d38747f038c170ddb193?default=mm", - "roles": [ - "admin" - ] - } - } - }, - "schema": { - "$ref": "#/components/schemas/UserObject" - } - } - } - } - } - }, - "delete": { - "operationId": "deleteUser", - "summary": "Delete a User", - "tags": [ - "Users" - ], - "security": [ - { - "BearerAuth": [ - "users" - ] - } - ], - "parameters": [ - { - "in": "path", - "name": "userID", - "schema": { - "type": "integer", - "minimum": 1 - }, - "required": true, - "description": "User ID", - "example": 2 - } - ], - "responses": { - "200": { - "description": "200 response", - "content": { - "application/json": { - "examples": { - "default": { - "value": true - } - }, - "schema": { - "type": "boolean" - } - } - } - } - } - } - }, - "/users/{userID}/auth": { - "put": { - "operationId": "updateUserAuth", - "summary": "Update a User's Authentication", - "tags": [ - "Users" - ], - "security": [ - { - "BearerAuth": [ - "users" - ] - } - ], - "parameters": [ - { - "in": "path", - "name": "userID", - "schema": { - "oneOf": [ - { - "type": "string", - "pattern": "^me$" - }, - { - "type": "integer", - "minimum": 1 - } - ] - }, - "required": true, - "description": "User ID or 'me' for yourself", - "example": 2 - }, - { - "in": "body", - "name": "user", - "description": "User Payload", - "required": true, - "schema": { - "$ref": "#/components/schemas/AuthObject" - } - } - ], - "responses": { - "200": { - "description": "200 response", - "content": { - "application/json": { - "examples": { - "default": { - "value": true - } - }, - "schema": { - "type": "boolean" - } - } - } - } - } - } - }, - "/users/{userID}/permissions": { - "put": { - "operationId": "updateUserPermissions", - "summary": "Update a User's Permissions", - "tags": [ - "Users" - ], - "security": [ - { - "BearerAuth": [ - "users" - ] - } - ], - "parameters": [ - { - "in": "path", - "name": "userID", - "schema": { - "type": "integer", - "minimum": 1 - }, - "required": true, - "description": "User ID", - "example": 2 - }, - { - "in": "body", - "name": "user", - "description": "Permissions Payload", - "required": true, - "schema": { - "$ref": "#/components/schemas/PermissionsObject" - } - } - ], - "responses": { - "200": { - "description": "200 response", - "content": { - "application/json": { - "examples": { - "default": { - "value": true - } - }, - "schema": { - "type": "boolean" - } - } - } - } - } - } - }, - "/users/{userID}/login": { - "put": { - "operationId": "loginAsUser", - "summary": "Login as this user", - "tags": [ - "Users" - ], - "security": [ - { - "BearerAuth": [ - "users" - ] - } - ], - "parameters": [ - { - "in": "path", - "name": "userID", - "schema": { - "type": "integer", - "minimum": 1 - }, - "required": true, - "description": "User ID", - "example": 2 - } - ], - "responses": { - "200": { - "description": "200 response", - "content": { - "application/json": { - "examples": { - "default": { - "value": { - "token": "eyJhbGciOiJSUzI1NiIsInR...16OjT8B3NLyXg", - "expires": "2020-01-31T10:56:23.239Z", - "user": { - "id": 1, - "created_on": "2020-01-30T10:43:44.000Z", - "modified_on": "2020-01-30T10:43:44.000Z", - "is_disabled": 0, - "email": "jc@jc21.com", - "name": "Jamie Curnow", - "nickname": "James", - "avatar": "//www.gravatar.com/avatar/3c8d73f45fd8763f827b964c76e6032a?default=mm", - "roles": [ - "admin" - ] - } - } - } - }, - "schema": { - "type": "object", - "description": "Login object", - "required": [ - "expires", - "token", - "user" - ], - "additionalProperties": false, - "properties": { - "expires": { - "description": "Token Expiry Unix Time", - "example": 1566540249, - "minimum": 1, - "type": "number" - }, - "token": { - "description": "JWT Token", - "example": "eyJhbGciOiJSUzUxMiIsInR5cCI6IkpXVCJ9.ey...xaHKYr3Kk6MvkUjcC4", - "type": "string" - }, - "user": { - "$ref": "#/components/schemas/UserObject" - } - } - } - } - } - } - } - } - }, - "/reports/hosts": { - "get": { - "operationId": "reportsHosts", - "summary": "Report on Host Statistics", - "tags": [ - "Reports" - ], - "security": [ - { - "BearerAuth": [ - "reports" - ] - } - ], - "responses": { - "200": { - "description": "200 response", - "content": { - "application/json": { - "examples": { - "default": { - "value": { - "proxy": 20, - "redirection": 1, - "stream": 0, - "dead": 1 - } - } - }, - "schema": { - "$ref": "#/components/schemas/HostReportObject" - } - } - } - } - } - } - }, - "/audit-log": { - "get": { - "operationId": "getAuditLog", - "summary": "Get Audit Log", - "tags": [ - "Audit Log" - ], - "security": [ - { - "BearerAuth": [ - "audit-log" - ] - } - ], - "responses": { - "200": { - "description": "200 response", - "content": { - "application/json": { - "examples": { - "default": { - "value": { - "proxy": 20, - "redirection": 1, - "stream": 0, - "dead": 1 - } - } - }, - "schema": { - "$ref": "#/components/schemas/HostReportObject" - } - } - } - } - } - } - } - }, - "components": { - "securitySchemes": { - "BearerAuth": { - "type": "http", - "scheme": "bearer" - } - }, - "schemas": { - "HealthObject": { - "type": "object", - "description": "Health object", - "additionalProperties": false, - "required": [ - "status", - "version" - ], - "properties": { - "status": { - "type": "string", - "description": "Healthy", - "example": "OK" - }, - "version": { - "type": "object", - "description": "The version object", - "example": { - "major": 2, - "minor": 0, - "revision": 0 - }, - "additionalProperties": false, - "required": [ - "major", - "minor", - "revision" - ], - "properties": { - "major": { - "type": "integer", - "minimum": 0 - }, - "minor": { - "type": "integer", - "minimum": 0 - }, - "revision": { - "type": "integer", - "minimum": 0 - } - } - } - } - }, - "TokenObject": { - "type": "object", - "description": "Token object", - "required": [ - "expires", - "token" - ], - "additionalProperties": false, - "properties": { - "expires": { - "description": "Token Expiry Unix Time", - "example": 1566540249, - "minimum": 1, - "type": "number" - }, - "token": { - "description": "JWT Token", - "example": "eyJhbGciOiJSUzUxMiIsInR5cCI6IkpXVCJ9.ey...xaHKYr3Kk6MvkUjcC4", - "type": "string" - } - } - }, - "SettingObject": { - "type": "object", - "description": "Setting object", - "required": [ - "id", - "name", - "description", - "value", - "meta" - ], - "additionalProperties": false, - "properties": { - "id": { - "type": "string", - "description": "Setting ID", - "minLength": 1, - "example": "default-site" - }, - "name": { - "type": "string", - "description": "Setting Display Name", - "minLength": 1, - "example": "Default Site" - }, - "description": { - "type": "string", - "description": "Meaningful description", - "minLength": 1, - "example": "What to show when Nginx is hit with an unknown Host" - }, - "value": { - "description": "Value in almost any form", - "example": "congratulations", - "oneOf": [ - { - "type": "string", - "minLength": 1 - }, - { - "type": "integer" - }, - { - "type": "object" - }, - { - "type": "number" - }, - { - "type": "array" - } - ] - }, - "meta": { - "description": "Extra metadata", - "example": {}, - "type": "object" - } - } - }, - "SettingsList": { - "type": "array", - "description": "Setting list", - "items": { - "$ref": "#/components/schemas/SettingObject" - } - }, - "UserObject": { - "type": "object", - "description": "User object", - "required": [ - "id", - "created_on", - "modified_on", - "is_disabled", - "email", - "name", - "nickname", - "avatar", - "roles" - ], - "additionalProperties": false, - "properties": { - "id": { - "type": "integer", - "description": "User ID", - "minimum": 1, - "example": 1 - }, - "created_on": { - "type": "string", - "description": "Created Date", - "example": "2020-01-30T09:36:08.000Z" - }, - "modified_on": { - "type": "string", - "description": "Modified Date", - "example": "2020-01-30T09:41:04.000Z" - }, - "is_disabled": { - "type": "integer", - "minimum": 0, - "maximum": 1, - "description": "Is user Disabled (0 = false, 1 = true)", - "example": 0 - }, - "email": { - "type": "string", - "description": "Email", - "minLength": 3, - "example": "jc@jc21.com" - }, - "name": { - "type": "string", - "description": "Name", - "minLength": 1, - "example": "Jamie Curnow" - }, - "nickname": { - "type": "string", - "description": "Nickname", - "example": "James" - }, - "avatar": { - "type": "string", - "description": "Gravatar URL based on email, without scheme", - "example": "//www.gravatar.com/avatar/6193176330f8d38747f038c170ddb193?default=mm" - }, - "roles": { - "description": "Roles applied", - "example": [ - "admin" - ], - "type": "array", - "items": { - "type": "string" - } - } - } - }, - "UsersList": { - "type": "array", - "description": "User list", - "items": { - "$ref": "#/components/schemas/UserObject" - } - }, - "AuthObject": { - "type": "object", - "description": "Authentication Object", - "required": [ - "type", - "secret" - ], - "properties": { - "type": { - "type": "string", - "pattern": "^password$", - "example": "password" - }, - "current": { - "type": "string", - "minLength": 1, - "maxLength": 64, - "example": "changeme" - }, - "secret": { - "type": "string", - "minLength": 8, - "maxLength": 64, - "example": "mySuperN3wP@ssword!" - } - } - }, - "PermissionsObject": { - "type": "object", - "properties": { - "visibility": { - "type": "string", - "description": "Visibility Type", - "enum": [ - "all", - "user" - ] - }, - "access_lists": { - "type": "string", - "description": "Access Lists Permissions", - "enum": [ - "hidden", - "view", - "manage" - ] - }, - "dead_hosts": { - "type": "string", - "description": "404 Hosts Permissions", - "enum": [ - "hidden", - "view", - "manage" - ] - }, - "proxy_hosts": { - "type": "string", - "description": "Proxy Hosts Permissions", - "enum": [ - "hidden", - "view", - "manage" - ] - }, - "redirection_hosts": { - "type": "string", - "description": "Redirection Permissions", - "enum": [ - "hidden", - "view", - "manage" - ] - }, - "streams": { - "type": "string", - "description": "Streams Permissions", - "enum": [ - "hidden", - "view", - "manage" - ] - }, - "certificates": { - "type": "string", - "description": "Certificates Permissions", - "enum": [ - "hidden", - "view", - "manage" - ] - } - } - }, - "HostReportObject": { - "type": "object", - "properties": { - "proxy": { - "type": "integer", - "description": "Proxy Hosts Count" - }, - "redirection": { - "type": "integer", - "description": "Redirection Hosts Count" - }, - "stream": { - "type": "integer", - "description": "Streams Count" - }, - "dead": { - "type": "integer", - "description": "404 Hosts Count" - } - } - } - } - } -} \ No newline at end of file diff --git a/backend/index.js b/backend/index.js index 0c08af328..d334a7c2d 100644 --- a/backend/index.js +++ b/backend/index.js @@ -1,23 +1,31 @@ #!/usr/bin/env node +const schema = require('./schema'); const logger = require('./logger').global; -function appStart () { +const IP_RANGES_FETCH_ENABLED = process.env.IP_RANGES_FETCH_ENABLED !== 'false'; + +async function appStart () { const migrate = require('./migrate'); const setup = require('./setup'); const app = require('./app'); - const apiValidator = require('./lib/validator/api'); const internalCertificate = require('./internal/certificate'); const internalIpRanges = require('./internal/ip_ranges'); return migrate.latest() .then(setup) + .then(schema.getCompiledSchema) .then(() => { - return apiValidator.loadSchemas; + if (IP_RANGES_FETCH_ENABLED) { + logger.info('IP Ranges fetch is enabled'); + return internalIpRanges.fetch().catch((err) => { + logger.error('IP Ranges fetch failed, continuing anyway:', err.message); + }); + } else { + logger.info('IP Ranges fetch is disabled by environment variable'); + } }) - .then(internalIpRanges.fetch) .then(() => { - internalCertificate.initTimer(); internalIpRanges.initTimer(); @@ -34,7 +42,7 @@ function appStart () { }); }) .catch((err) => { - logger.error(err.message); + logger.error(err.message, err); setTimeout(appStart, 1000); }); } @@ -45,3 +53,4 @@ try { logger.error(err.message, err); process.exit(1); } + diff --git a/backend/internal/access-list.js b/backend/internal/access-list.js index bfecf613d..f6043e18b 100644 --- a/backend/internal/access-list.js +++ b/backend/internal/access-list.js @@ -1,14 +1,15 @@ -const _ = require('lodash'); -const fs = require('fs'); -const batchflow = require('batchflow'); -const logger = require('../logger').access; -const error = require('../lib/error'); -const accessListModel = require('../models/access_list'); -const accessListAuthModel = require('../models/access_list_auth'); -const proxyHostModel = require('../models/proxy_host'); -const internalAuditLog = require('./audit-log'); -const internalNginx = require('./nginx'); -const utils = require('../lib/utils'); +const _ = require('lodash'); +const fs = require('fs'); +const batchflow = require('batchflow'); +const logger = require('../logger').access; +const error = require('../lib/error'); +const utils = require('../lib/utils'); +const accessListModel = require('../models/access_list'); +const accessListAuthModel = require('../models/access_list_auth'); +const accessListClientModel = require('../models/access_list_client'); +const proxyHostModel = require('../models/proxy_host'); +const internalAuditLog = require('./audit-log'); +const internalNginx = require('./nginx'); function omissions () { return ['is_deleted']; @@ -26,17 +27,20 @@ const internalAccessList = { .then((/*access_data*/) => { return accessListModel .query() - .omit(omissions()) .insertAndFetch({ name: data.name, + satisfy_any: data.satisfy_any, + pass_auth: data.pass_auth, owner_user_id: access.token.getUserId(1) - }); + }) + .then(utils.omitRow(omissions())); }) .then((row) => { data.id = row.id; - // Now add the items let promises = []; + + // Now add the items data.items.map((item) => { promises.push(accessListAuthModel .query() @@ -48,13 +52,27 @@ const internalAccessList = { ); }); + // Now add the clients + if (typeof data.clients !== 'undefined' && data.clients) { + data.clients.map((client) => { + promises.push(accessListClientModel + .query() + .insert({ + access_list_id: row.id, + address: client.address, + directive: client.directive + }) + ); + }); + } + return Promise.all(promises); }) .then(() => { // re-fetch with expansions return internalAccessList.get(access, { id: data.id, - expand: ['owner', 'items'] + expand: ['owner', 'items', 'clients', 'proxy_hosts.access_list.[clients,items]'] }, true /* <- skip masking */); }) .then((row) => { @@ -63,8 +81,8 @@ const internalAccessList = { return internalAccessList.build(row) .then(() => { - if (row.proxy_host_count) { - return internalNginx.reload(); + if (parseInt(row.proxy_host_count, 10)) { + return internalNginx.bulkGenerateConfigs('proxy_host', row.proxy_hosts); } }) .then(() => { @@ -100,7 +118,6 @@ const internalAccessList = { // Sanity check that something crazy hasn't happened throw new error.InternalValidationError('Access List could not be updated, IDs do not match: ' + row.id + ' !== ' + data.id); } - }) .then(() => { // patch name if specified @@ -109,7 +126,9 @@ const internalAccessList = { .query() .where({id: data.id}) .patch({ - name: data.name + name: data.name, + satisfy_any: data.satisfy_any, + pass_auth: data.pass_auth, }); } }) @@ -153,6 +172,38 @@ const internalAccessList = { }); } }) + .then(() => { + // Check for clients and add/update/remove them + if (typeof data.clients !== 'undefined' && data.clients) { + let promises = []; + + data.clients.map(function (client) { + if (client.address) { + promises.push(accessListClientModel + .query() + .insert({ + access_list_id: data.id, + address: client.address, + directive: client.directive + }) + ); + } + }); + + let query = accessListClientModel + .query() + .delete() + .where('access_list_id', data.id); + + return query + .then(() => { + // Add new items + if (promises.length) { + return Promise.all(promises); + } + }); + } + }) .then(() => { // Add to audit log return internalAuditLog.add(access, { @@ -166,16 +217,16 @@ const internalAccessList = { // re-fetch with expansions return internalAccessList.get(access, { id: data.id, - expand: ['owner', 'items'] + expand: ['owner', 'items', 'clients', 'proxy_hosts.[certificate,access_list.[clients,items]]'] }, true /* <- skip masking */); }) .then((row) => { return internalAccessList.build(row) .then(() => { - if (row.proxy_host_count) { - return internalNginx.reload(); + if (parseInt(row.proxy_host_count, 10)) { + return internalNginx.bulkGenerateConfigs('proxy_host', row.proxy_hosts); } - }) + }).then(internalNginx.reload) .then(() => { return internalAccessList.maskItems(row); }); @@ -201,38 +252,38 @@ const internalAccessList = { let query = accessListModel .query() .select('access_list.*', accessListModel.raw('COUNT(proxy_host.id) as proxy_host_count')) - .joinRaw('LEFT JOIN `proxy_host` ON `proxy_host`.`access_list_id` = `access_list`.`id` AND `proxy_host`.`is_deleted` = 0') + .leftJoin('proxy_host', function() { + this.on('proxy_host.access_list_id', '=', 'access_list.id') + .andOn('proxy_host.is_deleted', '=', 0); + }) .where('access_list.is_deleted', 0) .andWhere('access_list.id', data.id) - .allowEager('[owner,items,proxy_hosts]') - .omit(['access_list.is_deleted']) + .groupBy('access_list.id') + .allowGraph('[owner,items,clients,proxy_hosts.[certificate,access_list.[clients,items]]]') .first(); if (access_data.permission_visibility !== 'all') { query.andWhere('access_list.owner_user_id', access.token.getUserId(1)); } - // Custom omissions - if (typeof data.omit !== 'undefined' && data.omit !== null) { - query.omit(data.omit); - } - if (typeof data.expand !== 'undefined' && data.expand !== null) { - query.eager('[' + data.expand.join(', ') + ']'); + query.withGraphFetched('[' + data.expand.join(', ') + ']'); } - return query; + return query.then(utils.omitRow(omissions())); }) .then((row) => { - if (row) { - if (!skip_masking && typeof row.items !== 'undefined' && row.items) { - row = internalAccessList.maskItems(row); - } - - return _.omit(row, omissions()); - } else { + if (!row || !row.id) { throw new error.ItemNotFoundError(data.id); } + if (!skip_masking && typeof row.items !== 'undefined' && row.items) { + row = internalAccessList.maskItems(row); + } + // Custom omissions + if (typeof data.omit !== 'undefined' && data.omit !== null) { + row = _.omit(row, data.omit); + } + return row; }); }, @@ -246,10 +297,10 @@ const internalAccessList = { delete: (access, data) => { return access.can('access_lists:delete', data.id) .then(() => { - return internalAccessList.get(access, {id: data.id, expand: ['proxy_hosts', 'items']}); + return internalAccessList.get(access, {id: data.id, expand: ['proxy_hosts', 'items', 'clients']}); }) .then((row) => { - if (!row) { + if (!row || !row.id) { throw new error.ItemNotFoundError(data.id); } @@ -326,15 +377,17 @@ const internalAccessList = { let query = accessListModel .query() .select('access_list.*', accessListModel.raw('COUNT(proxy_host.id) as proxy_host_count')) - .joinRaw('LEFT JOIN `proxy_host` ON `proxy_host`.`access_list_id` = `access_list`.`id` AND `proxy_host`.`is_deleted` = 0') + .leftJoin('proxy_host', function() { + this.on('proxy_host.access_list_id', '=', 'access_list.id') + .andOn('proxy_host.is_deleted', '=', 0); + }) .where('access_list.is_deleted', 0) .groupBy('access_list.id') - .omit(['access_list.is_deleted']) - .allowEager('[owner,items]') + .allowGraph('[owner,items,clients]') .orderBy('access_list.name', 'ASC'); if (access_data.permission_visibility !== 'all') { - query.andWhere('owner_user_id', access.token.getUserId(1)); + query.andWhere('access_list.owner_user_id', access.token.getUserId(1)); } // Query is used for searching @@ -345,10 +398,10 @@ const internalAccessList = { } if (typeof expand !== 'undefined' && expand !== null) { - query.eager('[' + expand.join(', ') + ']'); + query.withGraphFetched('[' + expand.join(', ') + ']'); } - return query; + return query.then(utils.omitRows(omissions())); }) .then((rows) => { if (rows) { @@ -455,8 +508,13 @@ const internalAccessList = { if (typeof item.password !== 'undefined' && item.password.length) { logger.info('Adding: ' + item.username); - utils.exec('/usr/bin/htpasswd -b "' + htpasswd_file + '" "' + item.username + '" "' + item.password + '"') - .then((/*result*/) => { + utils.execFile('openssl', ['passwd', '-apr1', item.password]) + .then((res) => { + try { + fs.appendFileSync(htpasswd_file, item.username + ':' + res + '\n', {encoding: 'utf8'}); + } catch (err) { + reject(err); + } next(); }) .catch((err) => { diff --git a/backend/internal/audit-log.js b/backend/internal/audit-log.js index 422b4f467..60bdd2efa 100644 --- a/backend/internal/audit-log.js +++ b/backend/internal/audit-log.js @@ -1,5 +1,6 @@ -const error = require('../lib/error'); -const auditLogModel = require('../models/audit-log'); +const error = require('../lib/error'); +const auditLogModel = require('../models/audit-log'); +const {castJsonIfNeed} = require('../lib/helpers'); const internalAuditLog = { @@ -19,17 +20,17 @@ const internalAuditLog = { .orderBy('created_on', 'DESC') .orderBy('id', 'DESC') .limit(100) - .allowEager('[user]'); + .allowGraph('[user]'); // Query is used for searching - if (typeof search_query === 'string') { + if (typeof search_query === 'string' && search_query.length > 0) { query.where(function () { - this.where('meta', 'like', '%' + search_query + '%'); + this.where(castJsonIfNeed('meta'), 'like', '%' + search_query + '%'); }); } if (typeof expand !== 'undefined' && expand !== null) { - query.eager('[' + expand.join(', ') + ']'); + query.withGraphFetched('[' + expand.join(', ') + ']'); } return query; diff --git a/backend/internal/certificate.js b/backend/internal/certificate.js index 4f0caf3d6..f2e845a24 100644 --- a/backend/internal/certificate.js +++ b/backend/internal/certificate.js @@ -1,33 +1,44 @@ -const fs = require('fs'); const _ = require('lodash'); +const fs = require('fs'); +const https = require('https'); +const tempWrite = require('temp-write'); +const moment = require('moment'); +const archiver = require('archiver'); +const path = require('path'); +const { isArray } = require('lodash'); const logger = require('../logger').ssl; +const config = require('../lib/config'); const error = require('../lib/error'); +const utils = require('../lib/utils'); +const certbot = require('../lib/certbot'); const certificateModel = require('../models/certificate'); +const tokenModel = require('../models/token'); +const dnsPlugins = require('../global/certbot-dns-plugins.json'); const internalAuditLog = require('./audit-log'); -const tempWrite = require('temp-write'); -const utils = require('../lib/utils'); -const moment = require('moment'); -const debug_mode = process.env.NODE_ENV !== 'production' || !!process.env.DEBUG; -const le_staging = process.env.NODE_ENV !== 'production'; const internalNginx = require('./nginx'); const internalHost = require('./host'); -const certbot_command = '/usr/bin/certbot'; -const le_config = '/etc/letsencrypt.ini'; + + +const letsencryptStaging = config.useLetsencryptStaging(); +const letsencryptServer = config.useLetsencryptServer(); +const letsencryptConfig = '/etc/letsencrypt.ini'; +const certbotCommand = 'certbot'; function omissions() { - return ['is_deleted']; + return ['is_deleted', 'owner.is_deleted']; } const internalCertificate = { - allowed_ssl_files: ['certificate', 'certificate_key', 'intermediate_certificate'], - interval_timeout: 1000 * 60 * 60, // 1 hour - interval: null, - interval_processing: false, + allowedSslFiles: ['certificate', 'certificate_key', 'intermediate_certificate'], + intervalTimeout: 1000 * 60 * 60, // 1 hour + interval: null, + intervalProcessing: false, + renewBeforeExpirationBy: [30, 'days'], initTimer: () => { logger.info('Let\'s Encrypt Renewal Timer initialized'); - internalCertificate.interval = setInterval(internalCertificate.processExpiringHosts, internalCertificate.interval_timeout); + internalCertificate.interval = setInterval(internalCertificate.processExpiringHosts, internalCertificate.intervalTimeout); // And do this now as well internalCertificate.processExpiringHosts(); }, @@ -36,67 +47,58 @@ const internalCertificate = { * Triggered by a timer, this will check for expiring hosts and renew their ssl certs if required */ processExpiringHosts: () => { - if (!internalCertificate.interval_processing) { - internalCertificate.interval_processing = true; - logger.info('Renewing SSL certs close to expiry...'); - - let cmd = certbot_command + ' renew --non-interactive --quiet ' + - '--config "' + le_config + '" ' + - '--preferred-challenges "dns,http" ' + - '--disable-hook-validation ' + - (le_staging ? '--staging' : ''); - - return utils.exec(cmd) - .then((result) => { - if (result) { - logger.info('Renew Result: ' + result); + if (!internalCertificate.intervalProcessing) { + internalCertificate.intervalProcessing = true; + logger.info('Renewing SSL certs expiring within ' + internalCertificate.renewBeforeExpirationBy[0] + ' ' + internalCertificate.renewBeforeExpirationBy[1] + ' ...'); + + const expirationThreshold = moment().add(internalCertificate.renewBeforeExpirationBy[0], internalCertificate.renewBeforeExpirationBy[1]).format('YYYY-MM-DD HH:mm:ss'); + + // Fetch all the letsencrypt certs from the db that will expire within the configured threshold + certificateModel + .query() + .where('is_deleted', 0) + .andWhere('provider', 'letsencrypt') + .andWhere('expires_on', '<', expirationThreshold) + .then((certificates) => { + if (!certificates || !certificates.length) { + return null; } - return internalNginx.reload() - .then(() => { - logger.info('Renew Complete'); - return result; - }); - }) - .then(() => { - // Now go and fetch all the letsencrypt certs from the db and query the files and update expiry times - return certificateModel - .query() - .where('is_deleted', 0) - .andWhere('provider', 'letsencrypt') - .then((certificates) => { - if (certificates && certificates.length) { - let promises = []; - - certificates.map(function (certificate) { - promises.push( - internalCertificate.getCertificateInfoFromFile('/etc/letsencrypt/live/npm-' + certificate.id + '/fullchain.pem') - .then((cert_info) => { - return certificateModel - .query() - .where('id', certificate.id) - .andWhere('provider', 'letsencrypt') - .patch({ - expires_on: certificateModel.raw('FROM_UNIXTIME(' + cert_info.dates.to + ')') - }); - }) - .catch((err) => { - // Don't want to stop the train here, just log the error - logger.error(err.message); - }) - ); - }); + /** + * Renews must be run sequentially or we'll get an error 'Another + * instance of Certbot is already running.' + */ + let sequence = Promise.resolve(); + + certificates.forEach(function (certificate) { + sequence = sequence.then(() => + internalCertificate + .renew( + { + can: () => + Promise.resolve({ + permission_visibility: 'all', + }), + token: new tokenModel(), + }, + { id: certificate.id }, + ) + .catch((err) => { + // Don't want to stop the train here, just log the error + logger.error(err.message); + }), + ); + }); - return Promise.all(promises); - } - }); + return sequence; }) .then(() => { - internalCertificate.interval_processing = false; + logger.info('Completed SSL cert renew process'); + internalCertificate.intervalProcessing = false; }) .catch((err) => { logger.error(err); - internalCertificate.interval_processing = false; + internalCertificate.intervalProcessing = false; }); } }, @@ -112,13 +114,13 @@ const internalCertificate = { data.owner_user_id = access.token.getUserId(1); if (data.provider === 'letsencrypt') { - data.nice_name = data.domain_names.sort().join(', '); + data.nice_name = data.domain_names.join(', '); } return certificateModel .query() - .omit(omissions()) - .insertAndFetch(data); + .insertAndFetch(data) + .then(utils.omitRow(omissions())); }) .then((certificate) => { if (certificate.provider === 'letsencrypt') { @@ -141,36 +143,61 @@ const internalCertificate = { }); }) .then((in_use_result) => { - // 3. Generate the LE config - return internalNginx.generateLetsEncryptRequestConfig(certificate) - .then(internalNginx.reload) - .then(() => { + // With DNS challenge no config is needed, so skip 3 and 5. + if (certificate.meta.dns_challenge) { + return internalNginx.reload().then(() => { // 4. Request cert - return internalCertificate.requestLetsEncryptSsl(certificate); - }) - .then(() => { - // 5. Remove LE config - return internalNginx.deleteLetsEncryptRequestConfig(certificate); + return internalCertificate.requestLetsEncryptSslWithDnsChallenge(certificate); }) - .then(internalNginx.reload) - .then(() => { - // 6. Re-instate previously disabled hosts - return internalCertificate.enableInUseHosts(in_use_result); - }) - .then(() => { - return certificate; - }) - .catch((err) => { - // In the event of failure, revert things and throw err back - return internalNginx.deleteLetsEncryptRequestConfig(certificate) - .then(() => { - return internalCertificate.enableInUseHosts(in_use_result); - }) - .then(internalNginx.reload) - .then(() => { - throw err; - }); - }); + .then(internalNginx.reload) + .then(() => { + // 6. Re-instate previously disabled hosts + return internalCertificate.enableInUseHosts(in_use_result); + }) + .then(() => { + return certificate; + }) + .catch((err) => { + // In the event of failure, revert things and throw err back + return internalCertificate.enableInUseHosts(in_use_result) + .then(internalNginx.reload) + .then(() => { + throw err; + }); + }); + } else { + // 3. Generate the LE config + return internalNginx.generateLetsEncryptRequestConfig(certificate) + .then(internalNginx.reload) + .then(async() => await new Promise((r) => setTimeout(r, 5000))) + .then(() => { + // 4. Request cert + return internalCertificate.requestLetsEncryptSsl(certificate); + }) + .then(() => { + // 5. Remove LE config + return internalNginx.deleteLetsEncryptRequestConfig(certificate); + }) + .then(internalNginx.reload) + .then(() => { + // 6. Re-instate previously disabled hosts + return internalCertificate.enableInUseHosts(in_use_result); + }) + .then(() => { + return certificate; + }) + .catch((err) => { + // In the event of failure, revert things and throw err back + return internalNginx.deleteLetsEncryptRequestConfig(certificate) + .then(() => { + return internalCertificate.enableInUseHosts(in_use_result); + }) + .then(internalNginx.reload) + .then(() => { + throw err; + }); + }); + } }) .then(() => { // At this point, the letsencrypt cert should exist on disk. @@ -180,8 +207,9 @@ const internalCertificate = { return certificateModel .query() .patchAndFetchById(certificate.id, { - expires_on: certificateModel.raw('FROM_UNIXTIME(' + cert_info.dates.to + ')') + expires_on: moment(cert_info.dates.to, 'X').format('YYYY-MM-DD HH:mm:ss') }) + .then(utils.omitRow(omissions())) .then((saved_row) => { // Add cert data for audit log saved_row.meta = _.assign({}, saved_row.meta, { @@ -191,6 +219,13 @@ const internalCertificate = { return saved_row; }); }); + }).catch(async (error) => { + // Delete the certificate from the database if it was not created successfully + await certificateModel + .query() + .deleteById(certificate.id); + + throw error; }); } else { return certificate; @@ -233,8 +268,8 @@ const internalCertificate = { return certificateModel .query() - .omit(omissions()) .patchAndFetchById(row.id, data) + .then(utils.omitRow(omissions())) .then((saved_row) => { saved_row.meta = internalCertificate.cleanMeta(saved_row.meta); data.meta = internalCertificate.cleanMeta(data.meta); @@ -252,7 +287,7 @@ const internalCertificate = { meta: _.omit(data, ['expires_on']) // this prevents json circular reference because expires_on might be raw }) .then(() => { - return _.omit(saved_row, omissions()); + return saved_row; }); }); }); @@ -277,33 +312,99 @@ const internalCertificate = { .query() .where('is_deleted', 0) .andWhere('id', data.id) - .allowEager('[owner]') + .allowGraph('[owner]') + .allowGraph('[proxy_hosts]') + .allowGraph('[redirection_hosts]') + .allowGraph('[dead_hosts]') .first(); if (access_data.permission_visibility !== 'all') { query.andWhere('owner_user_id', access.token.getUserId(1)); } - // Custom omissions - if (typeof data.omit !== 'undefined' && data.omit !== null) { - query.omit(data.omit); - } - if (typeof data.expand !== 'undefined' && data.expand !== null) { - query.eager('[' + data.expand.join(', ') + ']'); + query.withGraphFetched('[' + data.expand.join(', ') + ']'); } - return query; + return query.then(utils.omitRow(omissions())); }) .then((row) => { - if (row) { - return _.omit(row, omissions()); - } else { + if (!row || !row.id) { throw new error.ItemNotFoundError(data.id); } + // Custom omissions + if (typeof data.omit !== 'undefined' && data.omit !== null) { + row = _.omit(row, data.omit); + } + return row; }); }, + /** + * @param {Access} access + * @param {Object} data + * @param {Number} data.id + * @returns {Promise} + */ + download: (access, data) => { + return new Promise((resolve, reject) => { + access.can('certificates:get', data) + .then(() => { + return internalCertificate.get(access, data); + }) + .then((certificate) => { + if (certificate.provider === 'letsencrypt') { + const zipDirectory = '/etc/letsencrypt/live/npm-' + data.id; + + if (!fs.existsSync(zipDirectory)) { + throw new error.ItemNotFoundError('Certificate ' + certificate.nice_name + ' does not exists'); + } + + let certFiles = fs.readdirSync(zipDirectory) + .filter((fn) => fn.endsWith('.pem')) + .map((fn) => fs.realpathSync(path.join(zipDirectory, fn))); + const downloadName = 'npm-' + data.id + '-' + `${Date.now()}.zip`; + const opName = '/tmp/' + downloadName; + internalCertificate.zipFiles(certFiles, opName) + .then(() => { + logger.debug('zip completed : ', opName); + const resp = { + fileName: opName + }; + resolve(resp); + }).catch((err) => reject(err)); + } else { + throw new error.ValidationError('Only Let\'sEncrypt certificates can be downloaded'); + } + }).catch((err) => reject(err)); + }); + }, + + /** + * @param {String} source + * @param {String} out + * @returns {Promise} + */ + zipFiles(source, out) { + const archive = archiver('zip', { zlib: { level: 9 } }); + const stream = fs.createWriteStream(out); + + return new Promise((resolve, reject) => { + source + .map((fl) => { + let fileName = path.basename(fl); + logger.debug(fl, 'added to certificate zip'); + archive.file(fl, { name: fileName }); + }); + archive + .on('error', (err) => reject(err)) + .pipe(stream); + + stream.on('close', () => resolve()); + archive.finalize(); + }); + }, + /** * @param {Access} access * @param {Object} data @@ -317,7 +418,7 @@ const internalCertificate = { return internalCertificate.get(access, {id: data.id}); }) .then((row) => { - if (!row) { + if (!row || !row.id) { throw new error.ItemNotFoundError(data.id); } @@ -365,8 +466,10 @@ const internalCertificate = { .query() .where('is_deleted', 0) .groupBy('id') - .omit(['is_deleted']) - .allowEager('[owner]') + .allowGraph('[owner]') + .allowGraph('[proxy_hosts]') + .allowGraph('[redirection_hosts]') + .allowGraph('[dead_hosts]') .orderBy('nice_name', 'ASC'); if (access_data.permission_visibility !== 'all') { @@ -376,15 +479,15 @@ const internalCertificate = { // Query is used for searching if (typeof search_query === 'string') { query.where(function () { - this.where('name', 'like', '%' + search_query + '%'); + this.where('nice_name', 'like', '%' + search_query + '%'); }); } if (typeof expand !== 'undefined' && expand !== null) { - query.eager('[' + expand.join(', ') + ']'); + query.withGraphFetched('[' + expand.join(', ') + ']'); } - return query; + return query.then(utils.omitRows(omissions())); }); }, @@ -416,11 +519,9 @@ const internalCertificate = { * @returns {Promise} */ writeCustomCert: (certificate) => { - if (debug_mode) { - logger.info('Writing Custom Certificate:', certificate); - } + logger.info('Writing Custom Certificate:', certificate); - let dir = '/data/custom_ssl/npm-' + certificate.id; + const dir = '/data/custom_ssl/npm-' + certificate.id; return new Promise((resolve, reject) => { if (certificate.provider === 'letsencrypt') { @@ -428,9 +529,9 @@ const internalCertificate = { return; } - let cert_data = certificate.meta.certificate; + let certData = certificate.meta.certificate; if (typeof certificate.meta.intermediate_certificate !== 'undefined') { - cert_data = cert_data + '\n' + certificate.meta.intermediate_certificate; + certData = certData + '\n' + certificate.meta.intermediate_certificate; } try { @@ -442,7 +543,7 @@ const internalCertificate = { return; } - fs.writeFile(dir + '/fullchain.pem', cert_data, function (err) { + fs.writeFile(dir + '/fullchain.pem', certData, function (err) { if (err) { reject(err); } else { @@ -492,7 +593,7 @@ const internalCertificate = { // Put file contents into an object let files = {}; _.map(data.files, (file, name) => { - if (internalCertificate.allowed_ssl_files.indexOf(name) !== -1) { + if (internalCertificate.allowedSslFiles.indexOf(name) !== -1) { files[name] = file.data.toString(); } }); @@ -550,7 +651,7 @@ const internalCertificate = { } _.map(data.files, (file, name) => { - if (internalCertificate.allowed_ssl_files.indexOf(name) !== -1) { + if (internalCertificate.allowedSslFiles.indexOf(name) !== -1) { row.meta[name] = file.data.toString(); } }); @@ -558,18 +659,17 @@ const internalCertificate = { // TODO: This uses a mysql only raw function that won't translate to postgres return internalCertificate.update(access, { id: data.id, - expires_on: certificateModel.raw('FROM_UNIXTIME(' + validations.certificate.dates.to + ')'), + expires_on: moment(validations.certificate.dates.to, 'X').format('YYYY-MM-DD HH:mm:ss'), domain_names: [validations.certificate.cn], meta: _.clone(row.meta) // Prevent the update method from changing this value that we'll use later }) .then((certificate) => { - console.log('ROWMETA:', row.meta); certificate.meta = row.meta; return internalCertificate.writeCustomCert(certificate); }); }) .then(() => { - return _.pick(row.meta, internalCertificate.allowed_ssl_files); + return _.pick(row.meta, internalCertificate.allowedSslFiles); }); }); }, @@ -583,18 +683,26 @@ const internalCertificate = { checkPrivateKey: (private_key) => { return tempWrite(private_key, '/tmp') .then((filepath) => { - return utils.exec('openssl rsa -in ' + filepath + ' -check -noout') - .then((result) => { - if (!result.toLowerCase().includes('key ok')) { - throw new error.ValidationError(result); - } - - fs.unlinkSync(filepath); - return true; - }).catch((err) => { - fs.unlinkSync(filepath); - throw new error.ValidationError('Certificate Key is not valid (' + err.message + ')', err); - }); + return new Promise((resolve, reject) => { + const failTimeout = setTimeout(() => { + reject(new error.ValidationError('Result Validation Error: Validation timed out. This could be due to the key being passphrase-protected.')); + }, 10000); + utils + .exec('openssl pkey -in ' + filepath + ' -check -noout 2>&1 ') + .then((result) => { + clearTimeout(failTimeout); + if (!result.toLowerCase().includes('key is valid')) { + reject(new error.ValidationError('Result Validation Error: ' + result)); + } + fs.unlinkSync(filepath); + resolve(true); + }) + .catch((err) => { + clearTimeout(failTimeout); + fs.unlinkSync(filepath); + reject(new error.ValidationError('Certificate Key is not valid (' + err.message + ')', err)); + }); + }); }); }, @@ -609,9 +717,9 @@ const internalCertificate = { return tempWrite(certificate, '/tmp') .then((filepath) => { return internalCertificate.getCertificateInfoFromFile(filepath, throw_expired) - .then((cert_data) => { + .then((certData) => { fs.unlinkSync(filepath); - return cert_data; + return certData; }).catch((err) => { fs.unlinkSync(filepath); throw err; @@ -627,33 +735,33 @@ const internalCertificate = { * @param {Boolean} [throw_expired] Throw when the certificate is out of date */ getCertificateInfoFromFile: (certificate_file, throw_expired) => { - let cert_data = {}; + let certData = {}; return utils.exec('openssl x509 -in ' + certificate_file + ' -subject -noout') .then((result) => { + // Examples: + // subject=CN = *.jc21.com // subject=CN = something.example.com - let regex = /(?:subject=)?[^=]+=\s+(\S+)/gim; - let match = regex.exec(result); - - if (typeof match[1] === 'undefined') { - throw new error.ValidationError('Could not determine subject from certificate: ' + result); + const regex = /(?:subject=)?[^=]+=\s+(\S+)/gim; + const match = regex.exec(result); + if (match && typeof match[1] !== 'undefined') { + certData['cn'] = match[1]; } - - cert_data['cn'] = match[1]; }) .then(() => { return utils.exec('openssl x509 -in ' + certificate_file + ' -issuer -noout'); }) + .then((result) => { + // Examples: // issuer=C = US, O = Let's Encrypt, CN = Let's Encrypt Authority X3 - let regex = /^(?:issuer=)?(.*)$/gim; - let match = regex.exec(result); - - if (typeof match[1] === 'undefined') { - throw new error.ValidationError('Could not determine issuer from certificate: ' + result); + // issuer=C = US, O = Let's Encrypt, CN = E5 + // issuer=O = NginxProxyManager, CN = NginxProxyManager Intermediate CA","O = NginxProxyManager, CN = NginxProxyManager Intermediate CA + const regex = /^(?:issuer=)?(.*)$/gim; + const match = regex.exec(result); + if (match && typeof match[1] !== 'undefined') { + certData['issuer'] = match[1]; } - - cert_data['issuer'] = match[1]; }) .then(() => { return utils.exec('openssl x509 -in ' + certificate_file + ' -dates -noout'); @@ -661,39 +769,39 @@ const internalCertificate = { .then((result) => { // notBefore=Jul 14 04:04:29 2018 GMT // notAfter=Oct 12 04:04:29 2018 GMT - let valid_from = null; - let valid_to = null; + let validFrom = null; + let validTo = null; - let lines = result.split('\n'); + const lines = result.split('\n'); lines.map(function (str) { - let regex = /^(\S+)=(.*)$/gim; - let match = regex.exec(str.trim()); + const regex = /^(\S+)=(.*)$/gim; + const match = regex.exec(str.trim()); if (match && typeof match[2] !== 'undefined') { - let date = parseInt(moment(match[2], 'MMM DD HH:mm:ss YYYY z').format('X'), 10); + const date = parseInt(moment(match[2], 'MMM DD HH:mm:ss YYYY z').format('X'), 10); if (match[1].toLowerCase() === 'notbefore') { - valid_from = date; + validFrom = date; } else if (match[1].toLowerCase() === 'notafter') { - valid_to = date; + validTo = date; } } }); - if (!valid_from || !valid_to) { + if (!validFrom || !validTo) { throw new error.ValidationError('Could not determine dates from certificate: ' + result); } - if (throw_expired && valid_to < parseInt(moment().format('X'), 10)) { + if (throw_expired && validTo < parseInt(moment().format('X'), 10)) { throw new error.ValidationError('Certificate has expired'); } - cert_data['dates'] = { - from: valid_from, - to: valid_to + certData['dates'] = { + from: validFrom, + to: validTo }; - return cert_data; + return certData; }).catch((err) => { throw new error.ValidationError('Certificate is not valid (' + err.message + ')', err); }); @@ -707,7 +815,7 @@ const internalCertificate = { * @returns {Object} */ cleanMeta: function (meta, remove) { - internalCertificate.allowed_ssl_files.map((key) => { + internalCertificate.allowedSslFiles.map((key) => { if (typeof meta[key] !== 'undefined' && meta[key]) { if (remove) { delete meta[key]; @@ -721,25 +829,27 @@ const internalCertificate = { }, /** + * Request a certificate using the http challenge * @param {Object} certificate the certificate row * @returns {Promise} */ requestLetsEncryptSsl: (certificate) => { logger.info('Requesting Let\'sEncrypt certificates for Cert #' + certificate.id + ': ' + certificate.domain_names.join(', ')); - let cmd = certbot_command + ' certonly --non-interactive ' + - '--config "' + le_config + '" ' + - '--cert-name "npm-' + certificate.id + '" ' + + const cmd = `${certbotCommand} certonly ` + + `--config '${letsencryptConfig}' ` + + '--work-dir "/tmp/letsencrypt-lib" ' + + '--logs-dir "/tmp/letsencrypt-log" ' + + `--cert-name "npm-${certificate.id}" ` + '--agree-tos ' + - '--email "' + certificate.meta.letsencrypt_email + '" ' + + '--authenticator webroot ' + + `--email '${certificate.meta.letsencrypt_email}' ` + '--preferred-challenges "dns,http" ' + - '--webroot ' + - '--domains "' + certificate.domain_names.join(',') + '" ' + - (le_staging ? '--staging' : ''); + `--domains "${certificate.domain_names.join(',')}" ` + + (letsencryptServer !== null ? `--server '${letsencryptServer}' ` : '') + + (letsencryptStaging && letsencryptServer === null ? '--staging ' : ''); - if (debug_mode) { - logger.info('Command:', cmd); - } + logger.info('Command:', cmd); return utils.exec(cmd) .then((result) => { @@ -748,6 +858,70 @@ const internalCertificate = { }); }, + /** + * @param {Object} certificate the certificate row + * @param {String} dns_provider the dns provider name (key used in `certbot-dns-plugins.json`) + * @param {String | null} credentials the content of this providers credentials file + * @param {String} propagation_seconds + * @returns {Promise} + */ + requestLetsEncryptSslWithDnsChallenge: async (certificate) => { + await certbot.installPlugin(certificate.meta.dns_provider); + const dnsPlugin = dnsPlugins[certificate.meta.dns_provider]; + logger.info(`Requesting Let'sEncrypt certificates via ${dnsPlugin.name} for Cert #${certificate.id}: ${certificate.domain_names.join(', ')}`); + + const credentialsLocation = '/etc/letsencrypt/credentials/credentials-' + certificate.id; + fs.mkdirSync('/etc/letsencrypt/credentials', { recursive: true }); + fs.writeFileSync(credentialsLocation, certificate.meta.dns_provider_credentials, {mode: 0o600}); + + // Whether the plugin has a --