├── test ├── restarted ├── test.json.lock ├── nginx │ ├── nginx.conf │ └── mechanic.conf ├── mechanic-overrides │ ├── mysite │ │ ├── top │ │ ├── location │ │ ├── proxy │ │ └── server │ ├── site1 │ │ ├── proxy │ │ ├── top │ │ ├── location │ │ └── server │ ├── site2 │ │ ├── proxy │ │ ├── top │ │ ├── location │ │ └── server │ ├── defaultsite │ │ ├── top │ │ ├── location │ │ ├── proxy │ │ └── server │ └── nondefaultsite │ │ ├── proxy │ │ ├── server │ │ ├── top │ │ └── location ├── initial-db.json ├── test.json └── test.js ├── .eslintignore ├── badges └── npm-audit-badge.svg ├── bin └── mechanic ├── .gitignore ├── .eslintrc ├── .github └── workflows │ └── test.yml ├── package.json ├── CHANGELOG.md ├── template.conf ├── app.js └── README.md /test/restarted: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/test.json.lock: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules -------------------------------------------------------------------------------- /badges/npm-audit-badge.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/nginx/nginx.conf: -------------------------------------------------------------------------------- 1 | # test conf file -------------------------------------------------------------------------------- /bin/mechanic: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | require('../app.js'); 4 | -------------------------------------------------------------------------------- /test/mechanic-overrides/mysite/top: -------------------------------------------------------------------------------- 1 | # Your custom nginx directives go here 2 | -------------------------------------------------------------------------------- /test/mechanic-overrides/site1/proxy: -------------------------------------------------------------------------------- 1 | # Your custom nginx directives go here 2 | -------------------------------------------------------------------------------- /test/mechanic-overrides/site1/top: -------------------------------------------------------------------------------- 1 | # Your custom nginx directives go here 2 | -------------------------------------------------------------------------------- /test/mechanic-overrides/site2/proxy: -------------------------------------------------------------------------------- 1 | # Your custom nginx directives go here 2 | -------------------------------------------------------------------------------- /test/mechanic-overrides/site2/top: -------------------------------------------------------------------------------- 1 | # Your custom nginx directives go here 2 | -------------------------------------------------------------------------------- /test/mechanic-overrides/defaultsite/top: -------------------------------------------------------------------------------- 1 | # Your custom nginx directives go here 2 | -------------------------------------------------------------------------------- /test/mechanic-overrides/mysite/location: -------------------------------------------------------------------------------- 1 | # Your custom nginx directives go here 2 | -------------------------------------------------------------------------------- /test/mechanic-overrides/mysite/proxy: -------------------------------------------------------------------------------- 1 | # Your custom nginx directives go here 2 | -------------------------------------------------------------------------------- /test/mechanic-overrides/mysite/server: -------------------------------------------------------------------------------- 1 | # Your custom nginx directives go here 2 | -------------------------------------------------------------------------------- /test/mechanic-overrides/site1/location: -------------------------------------------------------------------------------- 1 | # Your custom nginx directives go here 2 | -------------------------------------------------------------------------------- /test/mechanic-overrides/site1/server: -------------------------------------------------------------------------------- 1 | # Your custom nginx directives go here 2 | -------------------------------------------------------------------------------- /test/mechanic-overrides/site2/location: -------------------------------------------------------------------------------- 1 | # Your custom nginx directives go here 2 | -------------------------------------------------------------------------------- /test/mechanic-overrides/site2/server: -------------------------------------------------------------------------------- 1 | # Your custom nginx directives go here 2 | -------------------------------------------------------------------------------- /test/mechanic-overrides/defaultsite/location: -------------------------------------------------------------------------------- 1 | # Your custom nginx directives go here 2 | -------------------------------------------------------------------------------- /test/mechanic-overrides/defaultsite/proxy: -------------------------------------------------------------------------------- 1 | # Your custom nginx directives go here 2 | -------------------------------------------------------------------------------- /test/mechanic-overrides/defaultsite/server: -------------------------------------------------------------------------------- 1 | # Your custom nginx directives go here 2 | -------------------------------------------------------------------------------- /test/mechanic-overrides/nondefaultsite/proxy: -------------------------------------------------------------------------------- 1 | # Your custom nginx directives go here 2 | -------------------------------------------------------------------------------- /test/mechanic-overrides/nondefaultsite/server: -------------------------------------------------------------------------------- 1 | # Your custom nginx directives go here 2 | -------------------------------------------------------------------------------- /test/mechanic-overrides/nondefaultsite/top: -------------------------------------------------------------------------------- 1 | # Your custom nginx directives go here 2 | -------------------------------------------------------------------------------- /test/mechanic-overrides/nondefaultsite/location: -------------------------------------------------------------------------------- 1 | # Your custom nginx directives go here 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | npm-debug.log 2 | *.DS_Store 3 | node_modules 4 | tests/nginx/* 5 | tests/test.json* 6 | tests/mechanic-overrides 7 | .idea 8 | package-lock.json 9 | -------------------------------------------------------------------------------- /test/initial-db.json: -------------------------------------------------------------------------------- 1 | { 2 | "settings": { 3 | "conf": "./nginx", 4 | "logs": "./logs", 5 | "restart": "touch restarted", 6 | "overrides": "./mechanic-overrides" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "apostrophe", 3 | "rules": { 4 | "no-console": [1, { 5 | "allow": [ 6 | "warn", 7 | "error", 8 | "info" 9 | ] 10 | } 11 | ] 12 | } 13 | } -------------------------------------------------------------------------------- /test/test.json: -------------------------------------------------------------------------------- 1 | {"settings":{"conf":"./nginx","logs":"./logs","restart":"touch restarted","overrides":"./mechanic-overrides","bind":"*"},"sites":[{"shortname":"nondefaultsite","host":"nondefaultsite.com","backends":["localhost:3000"],"backendGroups":[{"path":"/","backends":["localhost:3000"]}]},{"shortname":"defaultsite","host":"defaultsite.com","default":true,"backends":["localhost:3000","localhost:4000/ci-server"],"backendGroups":[{"path":"/","backends":["localhost:3000"]},{"path":"/ci-server","backends":["localhost:4000"]}]}]} -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | branches: ["main"] 6 | pull_request: 7 | branches: ["*"] 8 | 9 | workflow_dispatch: 10 | 11 | jobs: 12 | test: 13 | runs-on: ubuntu-latest 14 | strategy: 15 | matrix: 16 | node-version: [18, 20] 17 | mongodb-version: [6.0, 7.0] 18 | 19 | steps: 20 | - name: Git checkout 21 | uses: actions/checkout@v4 22 | 23 | - name: Use Node.js ${{ matrix.node-version }} 24 | uses: actions/setup-node@v4 25 | with: 26 | node-version: ${{ matrix.node-version }} 27 | 28 | - name: Start MongoDB 29 | uses: supercharge/mongodb-github-action@1.11.0 30 | with: 31 | mongodb-version: ${{ matrix.mongodb-version }} 32 | 33 | - run: npm install 34 | 35 | - run: npm test 36 | env: 37 | CI: true 38 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mechanic", 3 | "version": "1.8.0", 4 | "description": "Manage nginx as a reverse proxy for node apps. Written with the Apostrophe CMS in mind.", 5 | "main": "app.js", 6 | "scripts": { 7 | "lint": "npm run eslint", 8 | "eslint": "eslint .", 9 | "test": "npm run lint && cd test && node test.js" 10 | }, 11 | "repository": { 12 | "type": "git", 13 | "url": "https://github.com/apostrophecms/mechanic" 14 | }, 15 | "keywords": [ 16 | "nginx", 17 | "apostrophe", 18 | "reverse proxy" 19 | ], 20 | "author": "Apostrophe Technologies, Inc.", 21 | "license": "MIT", 22 | "bugs": { 23 | "url": "https://github.com/apostrophecms/mechanic/issues" 24 | }, 25 | "homepage": "https://github.com/apostrophecms/mechanic", 26 | "dependencies": { 27 | "@apostrophecms/nunjucks": "^2.5.4", 28 | "boring": "^0.1.0", 29 | "lodash": "^4.0.0", 30 | "prettiest": "^1.1.0", 31 | "shell-escape": "^0.2.0", 32 | "shelljs": "^0.3.0" 33 | }, 34 | "devDependencies": { 35 | "eslint-config-apostrophe": "^5.0.0" 36 | }, 37 | "bin": { 38 | "mechanic": "./bin/mechanic" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /test/nginx/mechanic.conf: -------------------------------------------------------------------------------- 1 | 2 | # This configuration file was generated with mechanic. 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | upstream upstream-nondefaultsite-1 { 14 | server localhost:3000; 15 | } 16 | 17 | 18 | 19 | 20 | include "./mechanic-overrides/nondefaultsite/top"; 21 | 22 | server { 23 | 24 | gzip on; 25 | gzip_types text/css text/javascript image/svg+xml 26 | application/vnd.ms-fontobject application/x-font-ttf 27 | application/x-javascript application/javascript; 28 | 29 | listen *:80; 30 | 31 | server_name nondefaultsite.com; 32 | 33 | 34 | 35 | client_max_body_size 32M; 36 | 37 | access_log ./logs/nondefaultsite.access.log; 38 | error_log ./logs/nondefaultsite.error.log; 39 | 40 | 41 | include "./mechanic-overrides/nondefaultsite/server"; 42 | 43 | 44 | 45 | 46 | 47 | 48 | location @proxy-nondefaultsite-80 { 49 | 50 | proxy_pass http://upstream-nondefaultsite-1; 51 | 52 | 53 | 54 | 55 | proxy_next_upstream error timeout invalid_header http_500 http_502 56 | http_503 http_504; 57 | proxy_redirect off; 58 | proxy_buffering off; 59 | proxy_set_header Host $host; 60 | proxy_set_header X-Real-IP $remote_addr; 61 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 62 | proxy_set_header X-Forwarded-Proto $scheme; 63 | include "./mechanic-overrides/nondefaultsite/proxy"; 64 | } 65 | 66 | 67 | location / { 68 | 69 | 70 | try_files $uri @proxy-nondefaultsite-80; 71 | 72 | 73 | expires 7d; 74 | include "./mechanic-overrides/nondefaultsite/location"; 75 | } 76 | 77 | 78 | 79 | 80 | 81 | } 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | upstream upstream-defaultsite-1 { 92 | server localhost:3000; 93 | } 94 | 95 | upstream upstream-defaultsite-2 { 96 | server localhost:4000; 97 | } 98 | 99 | 100 | 101 | 102 | include "./mechanic-overrides/defaultsite/top"; 103 | 104 | server { 105 | 106 | gzip on; 107 | gzip_types text/css text/javascript image/svg+xml 108 | application/vnd.ms-fontobject application/x-font-ttf 109 | application/x-javascript application/javascript; 110 | 111 | listen *:80 default_server; 112 | 113 | server_name defaultsite.com; 114 | 115 | 116 | 117 | client_max_body_size 32M; 118 | 119 | access_log ./logs/defaultsite.access.log; 120 | error_log ./logs/defaultsite.error.log; 121 | 122 | 123 | include "./mechanic-overrides/defaultsite/server"; 124 | 125 | 126 | 127 | 128 | 129 | 130 | location @proxy-defaultsite-80 { 131 | 132 | proxy_pass http://upstream-defaultsite-1; 133 | 134 | 135 | 136 | 137 | proxy_next_upstream error timeout invalid_header http_500 http_502 138 | http_503 http_504; 139 | proxy_redirect off; 140 | proxy_buffering off; 141 | proxy_set_header Host $host; 142 | proxy_set_header X-Real-IP $remote_addr; 143 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 144 | proxy_set_header X-Forwarded-Proto $scheme; 145 | include "./mechanic-overrides/defaultsite/proxy"; 146 | } 147 | 148 | 149 | location / { 150 | 151 | 152 | try_files $uri @proxy-defaultsite-80; 153 | 154 | 155 | expires 7d; 156 | include "./mechanic-overrides/defaultsite/location"; 157 | } 158 | 159 | 160 | 161 | 162 | 163 | location /ci-server { 164 | 165 | proxy_pass http://upstream-defaultsite-2; 166 | 167 | proxy_set_header Host $host; 168 | proxy_set_header X-Real-IP $remote_addr; 169 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 170 | proxy_set_header X-Forwarded-Proto $scheme; 171 | } 172 | 173 | 174 | 175 | } 176 | 177 | 178 | server { 179 | listen *:80; 180 | server_name _defaultsite_80; 181 | # canonicalize 182 | 183 | location / { 184 | rewrite ^(.*)$ http://defaultsite.com$1 ; 185 | } 186 | } 187 | 188 | 189 | 190 | 191 | 192 | 193 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 1.8.0 2021-07-02 4 | * Specify `ssl_prefer_server_ciphers on`, which results in more secure cipher choices being chosen first and an "A" rating from SLL Labs without micromanagement of cipher settings. 5 | * Bug fix: ensure `site.backends` exists so a simple static site doesn't crash template generation. This bug was introduced in version 1.7.0. 6 | 7 | ## 1.7.0 8 | Support for path-specific backends, i.e. backends that only accept traffic for a certain path prefix. This is handy for routing traffic to microservices without subdomains. 9 | 10 | ## 1.6.1 11 | Adds previous TLS update across template. 12 | 13 | ## 1.6.0 14 | Adds support for TLS 1.3 and removes support for TLS 1.1, for security reasons. 15 | 16 | ## 1.5.0 17 | Adds http/2 support for https requests by using `mechanic set http2 true`. Adds a permanent option to turn default temporary redirects (302) into permanent (301) by using `--permanent=true`. This can be undone by using `--permanent=false`. 18 | 19 | ## 1.4.0 20 | Added the `--redirect=https://example.com` and `--redirect-full=https://example.com` options, to redirect all traffic to another site. If you want the rest of the URL after the hostname to be appended when redirecting, use `--redirect-full`. To send everything to the same place, use `--redirect`. 21 | 22 | ## 1.3.3 23 | Corrects a typo in the `--websockets` option that had required the singular form of the word. Spaces out entries when using `mechanic list` to view current sites. 24 | 25 | ## 1.3.2 26 | Adds JS linting, some code clean up. 27 | 28 | ## 1.3.1 29 | document `--websockets` flag. No code changes. 30 | 31 | ## 1.3.0 32 | optional `--websockets` flag to enable support for websockets in the app behind the proxy. Thanks to Ahmet Simsek. 33 | 34 | ## 1.2.5 35 | documentation update indicating that `client_max_body_size` works best in the `location` override file. Thanks to Bob Clewell of P'unk Avenue for this contribution. 36 | 37 | ## 1.2.4 38 | if `https` and `redirect-to-https` are active for the site, redirect straight to https when canonicalizing, avoid an extra http hop which was generating security scan complaints and adding a touch of latency. 39 | 40 | ## 1.2.3 41 | depend on `prettiest` 1.1.0 or better, as a way of making it hopefully easier to install by transitively depending on a newer version of `fs-ext`. 42 | 43 | ## 1.2.2 44 | added config for running tests on CircleCI. 45 | 46 | ## 1.2.1 47 | fixed bug introduced in 1.2.0 with the use of `let` to redeclare a variable that is already a function argument. 48 | 49 | ## 1.2.0 50 | `--https-upstream` option added; when present connections to backends are made via `https` rather than `http`. This is useful when the upstream servers are remote and not just next door on a secured local network. Of course, there is a performance impact. Thanks to Kevin S. (t3rminus) for this contribution. 51 | 52 | ## 1.1.0 53 | sites set `--default=true` are always moved to the end of the list, and the end of the generated nginx configuration file. This is helpful when reading `mechanic list` and also works around an issue we've seen in at least one case where nginx did not appear to honor its usual rule that a `server_name` match should always beat `default_server`. 54 | 55 | ## 1.0.2 56 | Canonicalization also applies to https. Of course it won't magically 57 | work for aliases your certificate doesn't cover, but it will work for 58 | www to bare domain or vice versa, or whatever your certificate does include. 59 | 60 | ## 1.0.1 61 | Moved standard gzip directives to the start of the server block. Otherwise responses proxied through to node are not compressed. A large performance win. 62 | 63 | ## 1.0.0 64 | Officially stable and following semantic versioning from here on out. Also added `top` and `server` override files and the `--index` option, and made `backends` optional when `static` is present. This allows the use of mechanic to set up very simple static websites. 65 | 66 | ## 0.1.13—0.1.14 67 | pass the `X-Forwarded-Proto` header for compatibility with the `secure` flag for session cookies, provided that Express is configured to trust the first proxy. 68 | 69 | Killed support for `tlsv1` as it is insecure. 70 | 71 | ## 0.1.12 72 | killed support for `sslv3` as it is insecure. 73 | 74 | ## 0.1.11 75 | parse `host:port` correctly with the `--backends` option. 76 | 77 | ## 0.1.10 78 | the `boring` dependency was missing, this is fixed. 79 | 80 | ## 0.1.9 81 | Accept `backend` as an alias for `backends`. Reject invalid hyphenated options passed to `add` and `update`, as their absence usually means you've mistyped something important. Don't crash nginx if there are no backends, just skip that site and print a warning. Use [boring](https://www.npmjs.com/package/boring) instead of `yargs`. 82 | 83 | ## 0.1.8 84 | load convenience overrides from suitably named nginx configuration files. 85 | 86 | ## 0.1.7 87 | set the ssl flag properly for nginx in the listen statement. 88 | 89 | ## 0.1.6 90 | look in the documented place for SSL certificates (/etc/nginx/certs). 91 | 92 | ## 0.1.5 93 | don't try to reject invalid arguments, as yargs helpfully introduces camel-cased versions of hyphenated arguments, causing false positives and breaking our hyphenated options. This isn't great; we should find out how to disable that behavior in yargs. 94 | 95 | ## 0.1.3 96 | corrected documentation for Apache fallback strategy. 97 | 98 | ## 0.1.1, 0.1.2 99 | `reset` command works. 100 | 101 | ## 0.1.0 102 | initial release. 103 | -------------------------------------------------------------------------------- /template.conf: -------------------------------------------------------------------------------- 1 | {# Let folks know this wasn't a manual configuration #} 2 | # This configuration file was generated with mechanic. 3 | 4 | {% macro httpsSettings(settings, site) %} 5 | ssl_protocols TLSv1.2 TLSv1.3; 6 | ssl_certificate {{ settings.conf }}/../certs/{{ site.shortname }}.cer; 7 | ssl_certificate_key {{ settings.conf }}/../certs/{{ site.shortname }}.key; 8 | ssl_prefer_server_ciphers on; 9 | {% endmacro %} 10 | 11 | {% macro server(site, settings, options) %} 12 | 13 | include "{{ settings.overrides }}/{{ site.shortname }}/top"; 14 | 15 | server { 16 | 17 | gzip on; 18 | gzip_types text/css text/javascript image/svg+xml 19 | application/vnd.ms-fontobject application/x-font-ttf 20 | application/x-javascript application/javascript; 21 | 22 | listen {{ settings.bind }}:{{ options.port }}{% if options.https %} ssl {% if settings.http2 %}http2{% endif %}{% endif %}{% if site.default and not site.canonical %} default_server{% endif %}; 23 | 24 | server_name {{ site.host }}{% if site.aliases and (not site.canonical) %} {{ site.aliases | join(" ") }}{% endif %}; 25 | 26 | {% if options.https %} 27 | {{ httpsSettings(settings, site) }} 28 | {% endif %} 29 | 30 | client_max_body_size 32M; 31 | 32 | access_log {{ settings.logs }}/{{ site.shortname }}.access.log; 33 | error_log {{ settings.logs }}/{{ site.shortname }}.error.log; 34 | 35 | {% if site.https and site['redirect-to-https'] and not options.https %} 36 | location / { 37 | rewrite ^(.*)$ https://{{ site.host }}$1{% if site.permanent %} permanent{% endif %}; 38 | } 39 | {% elif site['redirect'] %} 40 | location / { 41 | rewrite ^(.*)$ {{ site['redirect'] }}{% if site.permanent %} permanent{% endif %}; 42 | } 43 | {% elif site['redirect-full'] %} 44 | location / { 45 | rewrite ^(.*)$ {{ site['redirect-full'] }}$1{% if site.permanent %} permanent{% endif %}; 46 | } 47 | {% else %} 48 | include "{{ settings.overrides }}/{{ site.shortname }}/server"; 49 | 50 | {# We need a named location block in order to use try_files. #} 51 | 52 | {% set defaultBackendGroup = site.backendGroups[0] %} 53 | {% set proxyRoot = defaultBackendGroup and (defaultBackendGroup.path == '/') %} 54 | {% if proxyRoot %} 55 | location @proxy-{{ site.shortname }}-{{ options.port }} { 56 | {% if site['https-upstream'] %} 57 | proxy_pass https://upstream-{{ site.shortname }}-1; 58 | proxy_ssl_session_reuse on; 59 | proxy_ssl_protocols TLSv1.2 TLSv1.3; 60 | {% else %} 61 | proxy_pass http://upstream-{{ site.shortname }}-1; 62 | {% endif %} 63 | 64 | {% if site['websockets'] or site['websocket'] %} 65 | proxy_http_version 1.1; 66 | proxy_set_header Upgrade $http_upgrade; 67 | proxy_set_header Connection "upgrade"; 68 | {% endif %} 69 | 70 | proxy_next_upstream error timeout invalid_header http_500 http_502 71 | http_503 http_504; 72 | proxy_redirect off; 73 | proxy_buffering off; 74 | proxy_set_header Host $host; 75 | proxy_set_header X-Real-IP $remote_addr; 76 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 77 | proxy_set_header X-Forwarded-Proto $scheme; 78 | include "{{ settings.overrides }}/{{ site.shortname }}/proxy"; 79 | } 80 | {% endif %} 81 | 82 | location / { 83 | {%- if site.static %}root {{ site.static }};{% endif %} 84 | {% if site.autoindex %} 85 | autoindex on; 86 | {% if proxyRoot %} 87 | try_files $uri $uri/ @proxy-{{ site.shortname }}-{{ options.port }}; 88 | {% endif %} 89 | {% else %} 90 | {% if proxyRoot %} 91 | try_files $uri @proxy-{{ site.shortname }}-{{ options.port }}; 92 | {% endif %} 93 | {% endif %} 94 | expires 7d; 95 | include "{{ settings.overrides }}/{{ site.shortname }}/location"; 96 | } 97 | 98 | {% for backendGroup in site.backendGroups %} 99 | {% if backendGroup.path != '/' %} 100 | location {{ backendGroup.path }} { 101 | {% if site['https-upstream'] %} 102 | proxy_pass https://upstream-{{ site.shortname }}-{{ loop.index }}; 103 | proxy_ssl_session_reuse on; 104 | proxy_ssl_protocols TLSv1.2 TLSv1.3; 105 | {% else %} 106 | proxy_pass http://upstream-{{ site.shortname }}-{{ loop.index }}; 107 | {% endif %} 108 | proxy_set_header Host $host; 109 | proxy_set_header X-Real-IP $remote_addr; 110 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 111 | proxy_set_header X-Forwarded-Proto $scheme; 112 | } 113 | {% endif %} 114 | {% endfor %} 115 | {% endif %} 116 | } 117 | 118 | {% if site.default or (site.canonical and site.aliases) %} 119 | server { 120 | listen {{ settings.bind }}:{{ options.port }}{% if site.default and site.canonical %} default_server{% endif %}{% if options.https %} ssl {% if settings.http2 %}http2{% endif %}{% endif %}; 121 | server_name _{{ site.shortname }}_{{ options.port }}{% if site.aliases %} {{ site.aliases | join(' ') }}{% endif %}; 122 | # canonicalize 123 | {% if options.https %} 124 | {{ httpsSettings(settings, site) }} 125 | {% endif %} 126 | location / { 127 | rewrite ^(.*)$ {% if options.https or (site.https and site['redirect-to-https']) %}https:{% else %}http:{% endif %}//{{ site.host }}$1{% if site.permanent %} permanent{% endif %} ; 128 | } 129 | } 130 | {% endif %} 131 | {% endmacro %} 132 | 133 | {% macro renderSite(site, settings) %} 134 | {% for backendGroup in site.backendGroups %} 135 | upstream upstream-{{ site.shortname }}-{{ loop.index }} { 136 | {% for backend in backendGroup.backends -%} 137 | server {{ backend }}; 138 | {%- endfor %} 139 | } 140 | {% endfor %} 141 | 142 | {{ server(site, settings, { port: 80 }) }} 143 | 144 | {% if (site.https) %} 145 | {{ server(site, settings, { port: 443, https: true }) }} 146 | {% endif %} 147 | {% endmacro %} 148 | 149 | {% for site in sites %} 150 | {{ renderSite(site, settings) }} 151 | {% endfor %} 152 | -------------------------------------------------------------------------------- /test/test.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | 3 | const shelljs = require('shelljs'); 4 | 5 | if (fs.existsSync('./mechanic-overrides/mysite/location')) { 6 | fs.unlinkSync('./mechanic-overrides/mysite/location'); 7 | } 8 | 9 | if (fs.existsSync(__dirname + '/test.json')) { 10 | fs.unlinkSync(__dirname + '/test.json'); 11 | } 12 | 13 | if (!fs.existsSync(__dirname + '/nginx')) { 14 | fs.mkdirSync(__dirname + '/nginx'); 15 | } 16 | 17 | fs.writeFileSync(__dirname + '/nginx/nginx.conf', '# test conf file'); 18 | 19 | fs.writeFileSync(__dirname + '/test.json', fs.readFileSync(__dirname + '/initial-db.json', 'utf8')); 20 | 21 | shelljs.exec('node ../app.js --data=./test.json add mysite --host=mysite.com --backends=3000'); 22 | 23 | expect({ 24 | settings: { 25 | conf: './nginx', 26 | logs: './logs', 27 | restart: 'touch restarted', 28 | overrides: './mechanic-overrides', 29 | bind: '*' 30 | }, 31 | sites: [ 32 | { 33 | shortname: 'mysite', 34 | host: 'mysite.com', 35 | backends: [ 'localhost:3000' ], 36 | backendGroups: [ 37 | { 38 | path: '/', 39 | backends: [ 'localhost:3000' ] 40 | } 41 | ] 42 | } 43 | ] 44 | }, 'Test failed: adding a site should store the right JSON'); 45 | 46 | shelljs.exec('node ../app.js --data=./test.json update mysite --host=mysite.com --backend=localhost:3001'); 47 | 48 | expect({ 49 | settings: { 50 | conf: './nginx', 51 | logs: './logs', 52 | restart: 'touch restarted', 53 | overrides: './mechanic-overrides', 54 | bind: '*' 55 | }, 56 | sites: [ 57 | { 58 | shortname: 'mysite', 59 | host: 'mysite.com', 60 | backends: [ 'localhost:3001' ], 61 | backendGroups: [ 62 | { 63 | path: '/', 64 | backends: [ 'localhost:3001' ] 65 | } 66 | ] 67 | } 68 | ] 69 | }, 'Test failed: alias was not accepted, or update command rejected, or host:port parsed badly'); 70 | 71 | // back to port 3000 which other tests want to see 72 | 73 | shelljs.exec('node ../app.js --data=./test.json update mysite --host=mysite.com --backends=3000'); 74 | 75 | // test a bogus option 76 | 77 | const result = shelljs.exec('node ../app.js --data=./test.json update mysite --host=mysite.com --backends=3000 --ludicrous', { silent: true }); 78 | if (!result.output.match(/Unrecognized option: ludicrous/)) { 79 | console.error('Test failed: bogus option did not result in error.'); 80 | process.exit(1); 81 | } 82 | 83 | shelljs.exec('node ../app.js --data=./test.json update mysite --aliases=www.mysite.com,mysite.temporary.com'); 84 | 85 | expect({ 86 | settings: { 87 | conf: './nginx', 88 | logs: './logs', 89 | restart: 'touch restarted', 90 | overrides: './mechanic-overrides', 91 | bind: '*' 92 | }, 93 | sites: [ 94 | { 95 | shortname: 'mysite', 96 | host: 'mysite.com', 97 | backends: [ 'localhost:3000' ], 98 | backendGroups: [ 99 | { 100 | path: '/', 101 | backends: [ 'localhost:3000' ] 102 | } 103 | ], 104 | aliases: [ 'www.mysite.com', 'mysite.temporary.com' ] 105 | } 106 | ] 107 | }, 'Test failed: updating a site should store the right JSON'); 108 | 109 | shelljs.exec('node ../app.js --data=./test.json remove mysite'); 110 | 111 | expect({ 112 | settings: { 113 | conf: './nginx', 114 | logs: './logs', 115 | restart: 'touch restarted', 116 | overrides: './mechanic-overrides', 117 | bind: '*' 118 | }, 119 | sites: [] 120 | }, 'Test failed: removing a site should store the right JSON'); 121 | 122 | shelljs.exec('node ../app.js --data=./test.json add site1 --host=site1.com --backends=3000 --https'); 123 | shelljs.exec('node ../app.js --data=./test.json add site2 --host=site2.com --backends=3001 --https'); 124 | expect({ 125 | settings: { 126 | conf: './nginx', 127 | logs: './logs', 128 | restart: 'touch restarted', 129 | overrides: './mechanic-overrides', 130 | bind: '*' 131 | }, 132 | sites: [ 133 | { 134 | shortname: 'site1', 135 | host: 'site1.com', 136 | backends: [ 'localhost:3000' ], 137 | https: true, 138 | backendGroups: [ 139 | { 140 | path: '/', 141 | backends: [ 'localhost:3000' ] 142 | } 143 | ] 144 | }, 145 | { 146 | shortname: 'site2', 147 | host: 'site2.com', 148 | backends: [ 'localhost:3001' ], 149 | https: true, 150 | backendGroups: [ 151 | { 152 | path: '/', 153 | backends: [ 'localhost:3001' ] 154 | } 155 | ] 156 | } 157 | ] 158 | }, 'Test failed: adding two sites with https should store the right JSON'); 159 | 160 | shelljs.exec('node ../app.js --data=./test.json update site2 --host=site2.com --backends=3001 --https --redirect-to-https --websockets'); 161 | 162 | expect({ 163 | settings: { 164 | conf: './nginx', 165 | logs: './logs', 166 | restart: 'touch restarted', 167 | overrides: './mechanic-overrides', 168 | bind: '*' 169 | }, 170 | sites: [ 171 | { 172 | shortname: 'site1', 173 | host: 'site1.com', 174 | backends: [ 'localhost:3000' ], 175 | https: true, 176 | backendGroups: [ 177 | { 178 | path: '/', 179 | backends: [ 'localhost:3000' ] 180 | } 181 | ] 182 | }, 183 | { 184 | shortname: 'site2', 185 | host: 'site2.com', 186 | backends: [ 'localhost:3001' ], 187 | https: true, 188 | backendGroups: [ 189 | { 190 | path: '/', 191 | backends: [ 'localhost:3001' ] 192 | } 193 | ], 194 | 'redirect-to-https': true, 195 | websockets: true 196 | } 197 | ] 198 | }, 'Test failed: redirect-to-https should add the right JSON'); 199 | 200 | const output = shelljs.exec('node ../app.js --data=./test.json list', { silent: true }).output; 201 | 202 | const expected = 'mechanic set conf \'./nginx\' \n\n' + 203 | 'mechanic set logs \'./logs\' \n\n' + 204 | 'mechanic set restart \'touch restarted\' \n\n' + 205 | 'mechanic set overrides \'./mechanic-overrides\' \n\n' + 206 | 'mechanic add site1 \'--host=site1.com\' \'--backends=localhost:3000\' \'--https=true\' \n\n' + 207 | 'mechanic add site2 \'--host=site2.com\' \'--backends=localhost:3001\' \'--https=true\' \'--redirect-to-https=true\' \'--websockets=true\' \n\n'; 208 | 209 | if (output !== expected) { 210 | console.error('Test failed: --list did not output correct commands to establish the two sites again'); 211 | console.error('GOT:'); 212 | console.error(output); 213 | console.error('EXPECTED:'); 214 | console.error(expected); 215 | process.exit(1); 216 | } 217 | 218 | shelljs.exec('node ../app.js --data=./test.json remove site1'); 219 | shelljs.exec('node ../app.js --data=./test.json remove site2'); 220 | shelljs.exec('node ../app.js --data=./test.json add defaultsite --host=defaultsite.com --default --backends=3000'); 221 | shelljs.exec('node ../app.js --data=./test.json add nondefaultsite --host=nondefaultsite.com --backends=3000'); 222 | 223 | expect({ 224 | settings: { 225 | conf: './nginx', 226 | logs: './logs', 227 | restart: 'touch restarted', 228 | overrides: './mechanic-overrides', 229 | bind: '*' 230 | }, 231 | sites: [ 232 | { 233 | shortname: 'nondefaultsite', 234 | host: 'nondefaultsite.com', 235 | backends: [ 'localhost:3000' ], 236 | backendGroups: [ 237 | { 238 | path: '/', 239 | backends: [ 'localhost:3000' ] 240 | } 241 | ] 242 | }, 243 | { 244 | shortname: 'defaultsite', 245 | host: 'defaultsite.com', 246 | default: true, 247 | backends: [ 'localhost:3000' ], 248 | backendGroups: [ 249 | { 250 | path: '/', 251 | backends: [ 'localhost:3000' ] 252 | } 253 | ] 254 | } 255 | ] 256 | }, 'Test failed: default site should always wind up at the end of the list'); 257 | 258 | shelljs.exec('node ../app.js --data=./test.json update defaultsite --backends=localhost:3000,localhost:4000/ci-server'); 259 | 260 | expect({ 261 | settings: { 262 | conf: './nginx', 263 | logs: './logs', 264 | restart: 'touch restarted', 265 | overrides: './mechanic-overrides', 266 | bind: '*' 267 | }, 268 | sites: [ 269 | { 270 | shortname: 'nondefaultsite', 271 | host: 'nondefaultsite.com', 272 | backends: [ 'localhost:3000' ], 273 | backendGroups: [ 274 | { 275 | path: '/', 276 | backends: [ 'localhost:3000' ] 277 | } 278 | ] 279 | }, 280 | { 281 | shortname: 'defaultsite', 282 | host: 'defaultsite.com', 283 | default: true, 284 | backends: [ 'localhost:3000', 'localhost:4000/ci-server' ], 285 | backendGroups: [ 286 | { 287 | path: '/', 288 | backends: [ 'localhost:3000' ] 289 | }, 290 | { 291 | path: '/ci-server', 292 | backends: [ 'localhost:4000' ] 293 | } 294 | ] 295 | } 296 | ] 297 | }, 'Test failed: ci server backend not listed properly'); 298 | 299 | if (!fs.existsSync('./mechanic-overrides/mysite/location')) { 300 | console.error('location override file for mysite does not exist'); 301 | process.exit(1); 302 | } 303 | 304 | function expect(correct, message) { 305 | const data = JSON.parse(fs.readFileSync('test.json', 'utf8')); 306 | if (JSON.stringify(data) !== JSON.stringify(correct)) { 307 | console.error(message); 308 | console.error('EXPECTED:'); 309 | console.error(JSON.stringify(correct)); 310 | console.error('ACTUAL:'); 311 | console.error(JSON.stringify(data)); 312 | process.exit(1); 313 | } 314 | } 315 | -------------------------------------------------------------------------------- /app.js: -------------------------------------------------------------------------------- 1 | const argv = require('boring')(); 2 | const _ = require('lodash'); 3 | const fs = require('fs'); 4 | const shelljs = require('shelljs'); 5 | const shellEscape = require('shell-escape'); 6 | 7 | let dataFile; 8 | if (argv.data) { 9 | dataFile = argv.data; 10 | delete argv.data; 11 | } else { 12 | dataFile = '/var/lib/misc/mechanic.json'; 13 | // The Unix File Hierarchy Standard says that all distros should 14 | // have a /var/lib/misc folder for storage of "state files 15 | // that don't need a directory." But create it if it's 16 | // somehow missing (Mac for instance). 17 | if (!fs.existsSync('/var/lib/misc')) { 18 | fs.mkdirSync('/var/lib/misc', 0o700); 19 | } 20 | } 21 | 22 | const data = require('prettiest')({ json: dataFile }); 23 | 24 | const defaultSettings = { 25 | conf: '/etc/nginx/conf.d', 26 | overrides: '/etc/nginx/mechanic-overrides', 27 | logs: '/var/log/nginx', 28 | restart: 'nginx -s reload', 29 | bind: '*' 30 | }; 31 | 32 | _.defaults(data, { settings: {} }); 33 | _.defaults(data.settings, defaultSettings); 34 | 35 | const settings = data.settings; 36 | 37 | const nunjucks = require('@apostrophecms/nunjucks'); 38 | 39 | const command = argv._[0]; 40 | if (!command) { 41 | usage(); 42 | } 43 | 44 | const aliases = { 45 | backend: 'backends' 46 | }; 47 | 48 | const options = { 49 | host: 'string', 50 | backends: 'addresses', 51 | aliases: 'strings', 52 | canonical: 'boolean', 53 | default: 'boolean', 54 | static: 'string', 55 | autoindex: 'boolean', 56 | https: 'boolean', 57 | http2: 'boolean', 58 | 'redirect-to-https': 'boolean', 59 | 'https-upstream': 'boolean', 60 | websocket: 'boolean', // Included for accidental BC coverage. 61 | websockets: 'boolean', 62 | redirect: 'string', 63 | 'redirect-full': 'string', 64 | permanent: 'boolean', 65 | path: 'string' 66 | }; 67 | 68 | const parsers = { 69 | string: function(s) { 70 | return s.trim(); 71 | }, 72 | integer: function(s) { 73 | return parseInt(s, 10); 74 | }, 75 | integers: function(s) { 76 | return _.map(parsers.strings(s), function(s) { 77 | return parsers.integer(s); 78 | }); 79 | }, 80 | addresses: function(s) { 81 | return _.map(parsers.strings(s), function(s) { 82 | const matches = s.match(/^(([^:]+):)?(\d+)(\/.*)?$/); 83 | if (!matches) { 84 | throw 'A list of port numbers and/or address:port combinations with optional paths is expected, separated by commas'; 85 | } 86 | let host = 'localhost'; 87 | if (matches[2]) { 88 | host = matches[2]; 89 | } 90 | 91 | const port = matches[3]; 92 | const path = matches[4]; 93 | const pathString = (path != null) ? path : ''; 94 | return `${host}:${port}${pathString}`; 95 | }); 96 | }, 97 | strings: function(s) { 98 | return s.toString().split(/\s*,\s*/); 99 | }, 100 | boolean: function(s) { 101 | // eslint-disable-next-line eqeqeq 102 | return (s === 'true') || (s === 'on') || (s == 1); 103 | }, 104 | // Have a feeling we'll use this soon 105 | keyValue: function(s) { 106 | s = parsers.string(s); 107 | const o = {}; 108 | _.each(s, function(v) { 109 | const matches = v.match(/^([^:]+):(.*)$/); 110 | if (!matches) { 111 | throw 'Key-value pairs expected, like this: key:value,key:value'; 112 | } 113 | o[matches[1]] = matches[2]; 114 | }); 115 | return o; 116 | } 117 | }; 118 | 119 | const stringifiers = { 120 | string: function(s) { 121 | return s; 122 | }, 123 | integer: function(s) { 124 | return s; 125 | }, 126 | strings: function(s) { 127 | return s.join(','); 128 | }, 129 | boolean: function(s) { 130 | return s ? 'true' : 'false'; 131 | }, 132 | keyValue: function(o) { 133 | return _.map(o, function(v, k) { 134 | return k + ':' + v; 135 | }).join(','); 136 | }, 137 | addresses: function(s) { 138 | return s.join(','); 139 | } 140 | }; 141 | 142 | data.sites = data.sites || []; 143 | 144 | if (command === 'add') { 145 | update(true); 146 | } else if (command === 'update') { 147 | update(false); 148 | } else if (command === 'remove') { 149 | remove(); 150 | } else if (command === 'refresh') { 151 | refresh(); 152 | } else if (command === 'list') { 153 | list(); 154 | } else if (command === 'set') { 155 | set(); 156 | } else if (command === 'reset') { 157 | reset(); 158 | } else { 159 | usage(); 160 | } 161 | 162 | function usage(m) { 163 | if (m) { 164 | console.error(m); 165 | } 166 | console.error('See https://github.com/punkave/mechanic for usage.'); 167 | process.exit(1); 168 | } 169 | 170 | function set() { 171 | // Top-level settings: nginx conf folder, logs folder, 172 | // and restart command 173 | if (argv._.length !== 3) { 174 | usage('The "set" command requires two parameters:\n\nmechanic set key value'); 175 | } 176 | 177 | data.settings[argv._[1]] = argv._[2]; 178 | go(); 179 | } 180 | 181 | function update(add) { 182 | if (argv._.length !== 2) { 183 | usage('shortname argument is required; also --host'); 184 | } 185 | 186 | const shortname = argv._[1]; 187 | let site; 188 | 189 | if (add) { 190 | if (findSite(shortname)) { 191 | usage('Site already exists, use update'); 192 | } else { 193 | site = { shortname }; 194 | data.sites.push(site); 195 | } 196 | } else { 197 | site = findSite(shortname); 198 | if (!site) { 199 | usage('Unknown site: ' + shortname); 200 | } 201 | } 202 | 203 | _.each(argv, function(val, key) { 204 | if (key === '_') { 205 | return; 206 | } 207 | 208 | if (_.has(aliases, key)) { 209 | key = aliases[key]; 210 | } 211 | 212 | if (!_.has(options, key)) { 213 | usage('Unrecognized option: ' + key); 214 | } 215 | try { 216 | if (key === 'redirect') { 217 | delete site['redirect-full']; 218 | } else if (key === 'redirect-full') { 219 | delete site.redirect; 220 | } 221 | site[key] = parsers[options[key]](val); 222 | } catch (e) { 223 | console.error(e); 224 | usage('Value for ' + key + ' must be of type: ' + options[key]); 225 | } 226 | }); 227 | 228 | go(); 229 | } 230 | 231 | function remove() { 232 | if (argv._.length !== 2) { 233 | usage(); 234 | } 235 | 236 | const shortname = argv._[1]; 237 | 238 | let found = false; 239 | data.sites = _.filter(data.sites || [], function(site) { 240 | if (site.shortname === shortname) { 241 | found = true; 242 | return false; 243 | } 244 | return true; 245 | }); 246 | 247 | if (!found) { 248 | // It's not fatal but it's warning-worthy 249 | console.error('Not found: ' + shortname); 250 | return; 251 | } 252 | 253 | go(); 254 | } 255 | 256 | function refresh() { 257 | go(); 258 | } 259 | 260 | function validSiteFilter(site) { 261 | if ((!(site.backends && site.backends.length)) && (!site.static) && (!site.redirect) && (!site['redirect-full'])) { 262 | console.warn('WARNING: skipping ' + site.shortname + ' because no backends have been specified (hint: --backends=portnumber)'); 263 | return false; 264 | } 265 | return true; 266 | } 267 | 268 | function go() { 269 | 270 | // Reorder the sites so that default servers come after 271 | // all others. According to the nginx documentation this 272 | // shouldn't matter because any explicit server_name matches 273 | // should win, but we've seen exceptions, and this is 274 | // aesthetically pleasing anyway. -Tom 275 | 276 | _.each(data.sites, function(site, i) { 277 | site._index = i; 278 | }); 279 | 280 | data.sites.sort(function(a, b) { 281 | if (a.default === b.default) { 282 | if (a._index < b._index) { 283 | return -1; 284 | } else if (b._index > a._index) { 285 | return 1; 286 | } 287 | 288 | return 0; 289 | } else { 290 | if (a.default) { 291 | return 1; 292 | } else if (b.default) { 293 | return -1; 294 | } 295 | return 0; 296 | } 297 | }); 298 | 299 | _.each(data.sites, function(site) { 300 | delete site._index; 301 | }); 302 | 303 | let sites = _.filter(data.sites, validSiteFilter); 304 | 305 | sites = sites.map(site => { 306 | site.backends = site.backends || []; 307 | site.backends.sort((b1, b2) => { 308 | const p1 = pathOf(b1); 309 | const p2 = pathOf(b2); 310 | if (p1 < p2) { 311 | return -1; 312 | } else if (p2 > p1) { 313 | return 1; 314 | } else { 315 | return 0; 316 | } 317 | }); 318 | site.backendGroups = []; 319 | let lastPath = null; 320 | let group; 321 | for (const backend of site.backends) { 322 | if (pathOf(backend) !== lastPath) { 323 | group = { 324 | path: pathOf(backend), 325 | backends: [ withoutPath(backend) ] 326 | }; 327 | lastPath = pathOf(backend); 328 | } else { 329 | group.backends.push(withoutPath(backend)); 330 | } 331 | if (group.backends.length === 1) { 332 | site.backendGroups.push(group); 333 | } 334 | } 335 | return site; 336 | }); 337 | 338 | const template = fs.readFileSync(settings.template || (__dirname + '/template.conf'), 'utf8'); 339 | 340 | const output = nunjucks.renderString(template, { 341 | sites, 342 | settings 343 | }); 344 | 345 | // Set up include-able files to allow 346 | // easy customizations 347 | _.each(sites, function(site) { 348 | let folder = settings.overrides; 349 | if (!fs.existsSync(folder)) { 350 | fs.mkdirSync(folder); 351 | } 352 | folder += '/' + site.shortname; 353 | if (!fs.existsSync(folder)) { 354 | fs.mkdirSync(folder); 355 | } 356 | const files = [ 'location', 'proxy', 'server', 'top' ]; 357 | _.each(files, function(file) { 358 | const filename = folder + '/' + file; 359 | if (!fs.existsSync(filename)) { 360 | fs.writeFileSync(filename, '# Your custom nginx directives go here\n'); 361 | } 362 | }); 363 | }); 364 | 365 | fs.writeFileSync(settings.conf + '/mechanic.conf', output); 366 | 367 | if (settings.restart !== false) { 368 | const restart = settings.restart || 'service nginx reload'; 369 | if (shelljs.exec(restart).code !== 0) { 370 | console.error('ERROR: unable to reload nginx configuration!'); 371 | process.exit(3); 372 | } 373 | } 374 | 375 | // Under 0.12 (?) this doesn't want to terminate on its own, 376 | // not sure who the culprit is 377 | process.exit(0); 378 | } 379 | 380 | function findSite(shortname) { 381 | return _.find(data.sites, function(site) { 382 | return site.shortname === shortname; 383 | }); 384 | } 385 | 386 | function list() { 387 | _.each(data.settings, function(val, key) { 388 | if (val !== defaultSettings[key]) { 389 | console.info(shellEscape([ 'mechanic', 'set', key, val ]), '\n'); 390 | } 391 | }); 392 | _.each(data.sites, function(site) { 393 | const words = [ 'mechanic', 'add', site.shortname ]; 394 | _.each(site, function(val, key) { 395 | if (_.has(stringifiers, options[key])) { 396 | words.push('--' + key + '=' + stringifiers[options[key]](val)); 397 | } 398 | }); 399 | console.info(shellEscape(words), '\n'); 400 | }); 401 | } 402 | 403 | function reset() { 404 | data.settings = defaultSettings; 405 | data.sites = []; 406 | go(); 407 | } 408 | 409 | function pathOf(backend) { 410 | const slashAt = backend.indexOf('/'); 411 | if (slashAt !== -1) { 412 | return backend.substring(slashAt); 413 | } else { 414 | return '/'; 415 | } 416 | } 417 | 418 | function withoutPath(backend) { 419 | const slashAt = backend.indexOf('/'); 420 | if (slashAt !== -1) { 421 | return backend.substring(0, slashAt); 422 | } else { 423 | return backend; 424 | } 425 | } 426 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # mechanic 2 | 3 | ## Purpose 4 | 5 | [nginx](http://nginx.org) is a popular reverse proxy server among node developers. It's common to set up one or more node apps listening on high-numbered ports and use nginx virtual hosting and reverse proxy features to pass traffic to node. nginx can also serve static files better than node can, and it has battle-tested round-robin load balancing features. 6 | 7 | We've boiled down our favorite configuration recipes for nginx to a simple utility that takes care of spinning up and shutting down proxies for new node sites on a server. It can also handle load balancing, canonical redirects, direct delivery of static files and https configuration. It takes the place of manually editing nginx configuration files. 8 | 9 | ## Install 10 | 11 | **Step One:** install `nginx` on your Linux server. 12 | 13 | Under Ubuntu Linux that would be: 14 | 15 | ``` 16 | apt-get install nginx 17 | ``` 18 | 19 | Make sure Apache isn't in the way, already listening on port 80. Remove it really, really thoroughly. Or reconfigure it for an alternate port, like 9898, and set it up as a fallback as described below. 20 | 21 | **Step Two:** 22 | 23 | ``` 24 | npm install -g mechanic 25 | ``` 26 | 27 | NOTE: `mechanic` will reconfigure nginx after each command given to it. A strong effort is made not to mess up other uses of nginx. Mechanic's nginx configuration output is written to `/etc/nginx/conf.d/mechanic.conf`, where both Debian-flavored and Red Hat-flavored Linux will load it. No other nginx configuration files are touched. You can change the folder where `mechanic.conf` is written, see below. 28 | 29 | **Step Three:** 30 | 31 | Go nuts. 32 | 33 | Let's add a single proxy that talks to one node process, which is listening on port 3000 on the same server (`localhost`): 34 | 35 | _All commands must be run as root._ 36 | 37 | ## Adding a site 38 | 39 | ``` 40 | mechanic add mysite --host=mysite.com --backends=3000 41 | ``` 42 | 43 | Replace `mysite` with a good "shortname" for _your_ site— letters and numbers and underscores only, no leading digits. 44 | 45 | `mechanic` will reconfigure and restart `nginx` as you go along and remember everything you've asked it to include. 46 | 47 | ## Aliases: alternate hostnames 48 | 49 | Next we decide we want some aliases: other hostnames that deliver the same content. It's common to do this in the pre-launch period. With the `update` command we can add new options to a site without starting from scratch: 50 | 51 | ``` 52 | mechanic update mysite --aliases=www.mysite.com,mysite.temporary.com 53 | ``` 54 | 55 | ## Canonicalization: redirecting to the "real name" 56 | 57 | In production, it's better to redirect traffic so that everyone sees the same domain. Let's start redirecting from our aliases rather than keeping them in the address bar: 58 | 59 | ``` 60 | mechanic update mysite --canonical=true 61 | ``` 62 | 63 | ## Setting a default site 64 | 65 | We've realized this site should be the default site for the entire server. If a request arrives with a hostname that doesn't match any `--host` or `--aliases` list, it should always go to this site, redirecting first if the site is canonical. We can do that with `default`: 66 | 67 | ``` 68 | mechanic update mysite --default=true 69 | ``` 70 | 71 | **Warning:** If your server came with a default website already configured, 72 | like the `server` block that appears in `/etc/nginx/nginx.conf` in 73 | CentOS 7, you will need to comment that out to use this feature. `mechanic` 74 | does not mess with the rest of your nginx settings, that is up to you. 75 | 76 | ## Fast static file delivery 77 | 78 | Let's score a big performance win by serving our static files directly with nginx. This is simple: if a file matching the URL exists, nginx will serve it directly. Otherwise the request is still sent to node. All we have to do is tell nginx where our static files live. 79 | 80 | ``` 81 | mechanic update mysite --static=/opt/stagecoach/apps/mysite/current/public 82 | ``` 83 | 84 | _Browsers will cache the static files for up to 7 days. That's a good thing, but if you use this feature make sure any dynamically generated files have new filenames on each new deployment._ 85 | 86 | ## Serving `index.html` for bare directories 87 | 88 | When using `--static`, you can optionally enable serving `index.html` automatically when a URL matches a directory name by using the `--autoindex` option. 89 | 90 | ``` 91 | mechanic update mysite --autoindex 92 | ``` 93 | 94 | As with all boolean options you can change your mind later: 95 | 96 | ``` 97 | mechanic update mysite --autoindex=false 98 | ``` 99 | 100 | In a typical proxy configuration, this makes it possible to use an `index.html` file as a cached static version of a resource with a "pretty URL" like `/people` that would normally hit your back end server. 101 | 102 | ## Static websites 103 | 104 | Although static websites will never be a primary use case for `mechanic`, you can set up a perfectly reasonable static webserver like this: 105 | 106 | ``` 107 | mechanic add mysite --host=mysite.com --static=/var/www/html/mysite --autoindex 108 | ``` 109 | 110 | **The `backends` option is no longer mandatory when `--static` is present.** 111 | 112 | If you have more elaborate use cases that don't involve a reverse proxy, you should really create a separate nginx configuration file for that site. 113 | 114 | ## Load balancing 115 | 116 | Traffic is surging, so we've set up four node processes to take advantage of four cores. They are listening on ports 3000, 3001, 3002 and 3003. Let's tell nginx to distribute traffic to all of them: 117 | 118 | ``` 119 | mechanic update mysite --backends=3000,3001,3002,3003 120 | ``` 121 | 122 | ### Across two servers 123 | 124 | This time we want to load-balance between two separate back-end servers, each of which is listening on two ports: 125 | 126 | ``` 127 | mechanic update mysite --backends=192.168.1.2:3000,192.168.1.2:3001,192.168.1.3:3000,192.168.1.3:3001 128 | ``` 129 | 130 | _You can use hostnames too._ 131 | 132 | ### Secure backends 133 | 134 | If you're proxying to a remote server, it's a good idea to enable HTTPS there too, so your connection is secure end-to-end. If you use the `https-upstream` option, nginx will make requests to your backends using SSL. 135 | 136 | ``` 137 | mechanic update mysite --https-upstream 138 | ``` 139 | 140 | Note that this can introduce a significant performance overhead, as nginx will need to validate certificates and encrypt the connection with the backend. 141 | 142 | ### Backends for certain URL paths only 143 | 144 | You can configure a backend exclusively for with a certain path prefix: 145 | 146 | ``` 147 | mechanic update mysite --backends=3000,3001:/ci-server 148 | ``` 149 | 150 | This is also supported for backends not running on the same computer: 151 | 152 | ``` 153 | mechanic update mysite --backends=192.168.1.2:3000,192.168.1.2:4000/ci-server 154 | ``` 155 | 156 | The prefix **is included** in the URL passed through to the backend. 157 | 158 | If such a backend is present, matching requests are sent only to it. You may have more than one for the same path, in which case they are load balanced by nginx in the usual way. 159 | 160 | This feature is useful when microservices share a single hostname. 161 | 162 | ## Secure sites 163 | 164 | Now we've added ecommerce and we need a secure site: 165 | 166 | ``` 167 | mechanic update mysite --https=true 168 | ``` 169 | 170 | Now nginx will serve the site with `https` (as well as `http`) and look for `mysite.cer` and `mysite.key` in the folder `/etc/nginx/certs`. 171 | 172 | [See the nginx docs on how to handle intermediate certificates.](http://nginx.org/en/docs/http/configuring_https_servers.html) 173 | 174 | ## Redirecting to the secure site 175 | 176 | Next we decide we want the site to be secure all the time, redirecting any traffic that arrives at the insecure site: 177 | 178 | ``` 179 | mechanic update mysite --https=true --redirect-to-https=true 180 | ``` 181 | 182 | ## Redirecting to another site 183 | 184 | We can also redirect all traffic to a different site. To redirect 100% of the traffic to one specific URL, use `--redirect`: 185 | 186 | ``` 187 | mechanic update mysite --redirect=https://example.com 188 | ``` 189 | 190 | To instead append the rest of the original URL to a redirected domain, use `--redirect-full`: 191 | 192 | ``` 193 | mechanic update mysite --redirect-full=https://example.com 194 | ``` 195 | 196 | Setting `--redirect` clears `--redirect-full`, and vice versa. 197 | 198 | ## Enabling HTTP/2 199 | 200 | We can enable HTTP/2 by setting http2 to true: 201 | 202 | ``` 203 | mechanic set true 204 | ``` 205 | 206 | ## Disabling HTTP/2 207 | 208 | We can disable HTTP/2 by setting http2 to an empty string: 209 | 210 | ``` 211 | mechanic set '' 212 | ``` 213 | 214 | ## Permanent and Temporary Redirects 215 | 216 | All redirects are temporary by default. To make redirects permanent (301), use `--permanent=true`. To go back to a temporary (302) redirect, use `--permanent=false`. 217 | 218 | ``` 219 | mechanic update mysite --permanent=true 220 | ``` 221 | 222 | ## Shutting off HTTPS 223 | 224 | Now we've decided we don't want ecommerce anymore. Let's shut that off: 225 | 226 | ``` 227 | mechanic update mysite --https=false 228 | ``` 229 | 230 | ## Removing a site 231 | 232 | Now let's remove the site completely: 233 | 234 | ``` 235 | mechanic remove mysite 236 | ``` 237 | 238 | ## Disabling options 239 | 240 | You can disable any previously set option, such as `static`, by setting it to `false` or the empty string. 241 | 242 | ## Falling back to Apache 243 | 244 | If you also want to serve some content with Apache on the same server, first configure Apache to listen on port `9898` instead of `80`, then set up a default site for `mechanic` that forwards traffic there: 245 | 246 | ```javascript 247 | mechanic add apache --host=dummy --backends=9898 --default=true 248 | ``` 249 | 250 | We still need a `host` setting even for a default site (TODO: remove this requirement). 251 | 252 | Apache doesn't have to be your default. You could also use `--host` and set up individual sites to be forwarded to Apache. 253 | 254 | ## Global options 255 | 256 | There are a few global options you might want to change. Here's how. The values shown are the defaults. 257 | 258 | ### conf: nginx configuration file location 259 | 260 | ```javascript 261 | mechanic set conf /etc/nginx/conf.d 262 | ``` 263 | 264 | This is the folder where the `mechanic.conf` nginx configuration file 265 | will be created. Note that both Red Hat and Debian-flavored Linux 266 | load everything in this folder by default. 267 | 268 | ### restart: nginx restart command 269 | 270 | ```javascript 271 | mechanic set restart "nginx -s reload" 272 | ``` 273 | 274 | The command to restart `nginx`. 275 | 276 | _Don't forget the quotes if spaces are present._ That's just how the shell works, but it bears repeating. 277 | 278 | ### logs: webserver log file folder 279 | 280 | ```javascript 281 | mechanic set logs /var/log/nginx 282 | ``` 283 | 284 | If this isn't where you want your nginx access and error log files for 285 | each site, change the setting. 286 | 287 | ### bind: bind address 288 | 289 | ```javascript 290 | mechanic set bind "*" 291 | ``` 292 | 293 | By default, `mechanic` tells nginx to accept traffic on all IP addresses assigned to the server. (`*` means "everything.") If this isn't what you want, set a specific ip address with `bind`. 294 | 295 | _If you reset this setting to `_` make sure you quote it, so the shell doesn't give you a list of filenames.\* 296 | 297 | ### Enabling websockets 298 | 299 | By default, nginx does not proxy websockets. You can enable this by passing the `--websockets` flag: 300 | 301 | ``` 302 | mechanic update mysite --websockets 303 | ``` 304 | 305 | This [enables websockets proxying per the nginx documentation](http://nginx.org/en/docs/http/websocket.html) by setting the HTTP version for the proxy to 1.1 and setting the Upgrade header. 306 | 307 | As with other boolean flags you can turn this off again with `--websockets=false`. 308 | 309 | ### template: custom nginx template file 310 | 311 | ```javascript 312 | mechanic set template /etc/mechanic/custom.conf 313 | ``` 314 | 315 | You don't have to use our nginx configuration template. 316 | 317 | Take a look at the file `template.conf` in the `nginx` npm module. It's just a [nunjucks](http://mozilla.github.io/nunjucks/) template that builds an `nginx` configuration based on your `mechanic` settings. 318 | 319 | You can copy that template anywhere you like, make your own modifications, then use `mechanic set template` to tell `mechanic` where to find it. 320 | 321 | ### Lazy overrides 322 | 323 | If you don't want to customize our template, check out the convenience override files that `mechanic` creates for you: 324 | 325 | ``` 326 | /etc/nginx/mechanic-overrides/myshortname/top 327 | /etc/nginx/mechanic-overrides/myshortname/server 328 | /etc/nginx/mechanic-overrides/myshortname/location 329 | /etc/nginx/mechanic-overrides/myshortname/proxy 330 | ``` 331 | 332 | `top` is loaded before any of mechanic's directives for that site. Use it when nothing else fits. 333 | 334 | `server` is included inside the `server` block for the site, just before the `location` block, when `redirect-to-https` is not in effect. It is a good place to change a setting like `access_log`. 335 | 336 | `location` is included inside the `location` block, and is a good place to add something like CORS headers for static font files. It is also a good place to change the `client_max_body_size` directive. 337 | 338 | `proxy` is loaded inside the proxy server configuration and is ideal if you need to override mechanic's proxy settings. 339 | 340 | These files start out empty; you can add whatever you like. 341 | 342 | Of course, if this isn't enough flexibility for your needs, you can create a custom template. 343 | 344 | ## Refreshing your nginx configuration 345 | 346 | Maybe you updated mechanic with `npm update -g mechanic` and you want our 347 | latest configuration. Maybe you edited your custom template. Either way, 348 | you want to rebuild your nginx configuration without changing any 349 | settings: 350 | 351 | ``` 352 | mechanic refresh 353 | ``` 354 | 355 | ## Resetting to the defaults 356 | 357 | To completely reset mechanic, throwing away everything it knows: 358 | 359 | ```javascript 360 | mechanic reset 361 | ``` 362 | 363 | _Warning:_ like it says, this will completely reset your configuration and forget everything you've done. Don't do that unless you really want to. 364 | 365 | ## Listing your configuration settings 366 | 367 | `mechanic list` 368 | 369 | This gives you back commands sufficient to set them up the same way again. Great for copying to another server. Here's some sample output: 370 | 371 | ``` 372 | mechanic set restart "/usr/sbin/nginx -s reload" 373 | mechanic add test --host=test.com --aliases=www.test.com --canonical=true --https=true 374 | mechanic add test2 --host=test2.com --aliases=www.test2.com,test2-prelaunch.mycompany.com 375 | ``` 376 | 377 | If you want to wipe the configuration on another server before applying these commands there, use `mechanic reset`. 378 | 379 | ## Custom nginx templates 380 | 381 | You don't have to use our nginx configuration template. 382 | 383 | Take a look at the file `template.conf` in the `nginx` npm module. It's just a [nunjucks](http://mozilla.github.io/nunjucks/) template that builds an `nginx` configuration based on your `mechanic` settings. 384 | 385 | ## Custom nginx path 386 | 387 | If you use brew (a package manager for mac) to install nginx, nginx install path will be `/usr/local/etc/nginx`. 388 | Mechanic default nginx path is `/etc/nginx`. 389 | You can change default nginx path below: 390 | 391 | ``` 392 | mechanic set restart 'brew services restart nginx' 393 | mechanic set conf '/usr/local/etc/nginx/conf.d' 394 | mechanic set overrides /usr/local/etc/nginx/mechanic-overrides 395 | mechanic set logs /usr/local/var/log/nginx 396 | ``` 397 | 398 | ## Storing the database in a different place 399 | 400 | It's stored in `/var/lib/misc/mechanic.json`. That's [one hundred percent correct according to the filesystem hierarchy standard](http://www.pathname.com/fhs/pub/fhs-2.3.pdf), adhered to by all major Linux distributions and many other flavors of Unix. But if you absolutely insist, you can use the `--data` option to specify another location. You'll have to do it every time you run `mechanic`, though. That's why we only use this option for unit tests. 401 | 402 | If necessary `mechanic` will create `/var/lib/misc`. 403 | 404 | ## Credits 405 | 406 | `mechanic` was created to facilitate our work at [P'unk Avenue](http://punkave.com). We use it to host sites powered by [ApostropheCMS](https://apostrophecms.org). 407 | --------------------------------------------------------------------------------