From 1b72db184b21c16254d89437b2f43a7c94081421 Mon Sep 17 00:00:00 2001 From: jacob-tate Date: Tue, 26 Aug 2025 02:08:25 -0700 Subject: [PATCH 1/2] Initial Commit adding load balancer functions --- backend/internal/proxy-host.js | 31 ++- backend/internal/upstream.js | 176 ++++++++++++++++++ backend/internal/user.js | 3 +- backend/lib/access.js | 3 +- backend/lib/access/upstreams-create.json | 28 +++ backend/lib/access/upstreams-delete.json | 28 +++ backend/lib/access/upstreams-get.json | 28 +++ backend/lib/access/upstreams-list.json | 28 +++ backend/lib/access/upstreams-update.json | 28 +++ .../migrations/20250825073649_upstreams.js | 71 +++++++ backend/models/proxy_host.js | 12 ++ backend/models/upstream.js | 70 +++++++ backend/routes/main.js | 1 + backend/routes/nginx/upstreams.js | 73 ++++++++ .../schema/components/proxy-host-object.json | 18 +- .../schema/components/upstream-object.json | 58 ++++++ .../paths/nginx/proxy-hosts/hostID/put.json | 6 +- .../schema/paths/nginx/proxy-hosts/post.json | 32 +++- backend/setup.js | 1 + backend/templates/proxy_host.conf | 22 ++- .../conf.d/include/loadbalancer-proxy.conf | 8 + frontend/js/app/api.js | 37 ++++ frontend/js/app/controller.js | 43 ++++- frontend/js/app/nginx/proxy/form.ejs | 21 ++- frontend/js/app/nginx/proxy/form.js | 99 ++++++++-- frontend/js/app/nginx/proxy/list/item.ejs | 16 +- frontend/js/app/nginx/proxy/list/item.js | 9 +- frontend/js/app/nginx/proxy/main.js | 4 +- .../js/app/nginx/proxy/upstream-list-item.ejs | 8 + frontend/js/app/nginx/upstreams/delete.ejs | 20 ++ frontend/js/app/nginx/upstreams/delete.js | 32 ++++ frontend/js/app/nginx/upstreams/form.ejs | 49 +++++ frontend/js/app/nginx/upstreams/form.js | 103 ++++++++++ .../app/nginx/upstreams/form/form-server.ejs | 18 ++ .../app/nginx/upstreams/form/form-server.js | 26 +++ frontend/js/app/nginx/upstreams/list/item.ejs | 30 +++ frontend/js/app/nginx/upstreams/list/item.js | 33 ++++ frontend/js/app/nginx/upstreams/list/main.ejs | 12 ++ frontend/js/app/nginx/upstreams/list/main.js | 32 ++++ frontend/js/app/nginx/upstreams/main.ejs | 28 +++ frontend/js/app/nginx/upstreams/main.js | 104 +++++++++++ frontend/js/app/router.js | 1 + frontend/js/app/ui/menu/main.ejs | 3 + frontend/js/app/user/permissions.ejs | 3 +- frontend/js/app/user/permissions.js | 8 +- frontend/js/i18n/messages.json | 3 + frontend/js/models/proxy-host.js | 2 + frontend/js/models/upstream-server.js | 18 ++ frontend/js/models/upstream.js | 23 +++ 49 files changed, 1470 insertions(+), 40 deletions(-) create mode 100644 backend/internal/upstream.js create mode 100644 backend/lib/access/upstreams-create.json create mode 100644 backend/lib/access/upstreams-delete.json create mode 100644 backend/lib/access/upstreams-get.json create mode 100644 backend/lib/access/upstreams-list.json create mode 100644 backend/lib/access/upstreams-update.json create mode 100755 backend/migrations/20250825073649_upstreams.js create mode 100644 backend/models/upstream.js create mode 100644 backend/routes/nginx/upstreams.js create mode 100644 backend/schema/components/upstream-object.json create mode 100644 docker/rootfs/etc/nginx/conf.d/include/loadbalancer-proxy.conf create mode 100644 frontend/js/app/nginx/proxy/upstream-list-item.ejs create mode 100644 frontend/js/app/nginx/upstreams/delete.ejs create mode 100644 frontend/js/app/nginx/upstreams/delete.js create mode 100644 frontend/js/app/nginx/upstreams/form.ejs create mode 100644 frontend/js/app/nginx/upstreams/form.js create mode 100644 frontend/js/app/nginx/upstreams/form/form-server.ejs create mode 100644 frontend/js/app/nginx/upstreams/form/form-server.js create mode 100644 frontend/js/app/nginx/upstreams/list/item.ejs create mode 100644 frontend/js/app/nginx/upstreams/list/item.js create mode 100644 frontend/js/app/nginx/upstreams/list/main.ejs create mode 100644 frontend/js/app/nginx/upstreams/list/main.js create mode 100644 frontend/js/app/nginx/upstreams/main.ejs create mode 100644 frontend/js/app/nginx/upstreams/main.js create mode 100644 frontend/js/models/upstream-server.js create mode 100644 frontend/js/models/upstream.js diff --git a/backend/internal/proxy-host.js b/backend/internal/proxy-host.js index 32f2bc0dc..8191cc4a5 100644 --- a/backend/internal/proxy-host.js +++ b/backend/internal/proxy-host.js @@ -49,6 +49,15 @@ const internalProxyHost = { data.owner_user_id = access.token.getUserId(1); data = internalHost.cleanSslHstsData(data); + // If upstream is used, clear forwarding fields + if (data.upstream_id) { + data.forward_host = ''; + data.forward_port = 0; + } + + // This is a UI-only field, remove it + delete data.forward_to_type; + // Fix for db field not having a default value // for this optional field. if (typeof data.advanced_config === 'undefined') { @@ -81,7 +90,7 @@ const internalProxyHost = { // re-fetch with cert return internalProxyHost.get(access, { id: row.id, - expand: ['certificate', 'owner', 'access_list.[clients,items]'] + expand: ['certificate', 'owner', 'access_list.[clients,items]', 'upstream'] }); }) .then((row) => { @@ -174,6 +183,18 @@ const internalProxyHost = { data = internalHost.cleanSslHstsData(data, row); + // If upstream is used, clear forwarding fields + if (data.upstream_id) { + data.forward_host = ''; + data.forward_port = 0; + } else if (data.upstream_id === 0) { + // Upstream was removed, make sure it's cleared + data.upstream_id = 0; + } + + // This is a UI-only field, remove it + delete data.forward_to_type; + return proxyHostModel .query() .where({id: data.id}) @@ -195,7 +216,7 @@ const internalProxyHost = { .then(() => { return internalProxyHost.get(access, { id: data.id, - expand: ['owner', 'certificate', 'access_list.[clients,items]'] + expand: ['owner', 'certificate', 'access_list.[clients,items]', 'upstream'] }) .then((row) => { if (!row.enabled) { @@ -232,7 +253,7 @@ const internalProxyHost = { .query() .where('is_deleted', 0) .andWhere('id', data.id) - .allowGraph('[owner,access_list.[clients,items],certificate]') + .allowGraph('[owner,access_list.[clients,items],certificate,upstream]') .first(); if (access_data.permission_visibility !== 'all') { @@ -315,7 +336,7 @@ const internalProxyHost = { .then(() => { return internalProxyHost.get(access, { id: data.id, - expand: ['certificate', 'owner', 'access_list'] + expand: ['certificate', 'owner', 'access_list', 'upstream'] }); }) .then((row) => { @@ -416,7 +437,7 @@ const internalProxyHost = { .query() .where('is_deleted', 0) .groupBy('id') - .allowGraph('[owner,access_list,certificate]') + .allowGraph('[owner,access_list,certificate,upstream]') .orderBy(castJsonIfNeed('domain_names'), 'ASC'); if (access_data.permission_visibility !== 'all') { diff --git a/backend/internal/upstream.js b/backend/internal/upstream.js new file mode 100644 index 000000000..b76944f4f --- /dev/null +++ b/backend/internal/upstream.js @@ -0,0 +1,176 @@ +const _ = require('lodash'); +const error = require('../lib/error'); +const utils = require('../lib/utils'); +const upstreamModel = require('../models/upstream'); +const internalNginx = require('./nginx'); +const internalAuditLog = require('./audit-log'); +const proxyHostModel = require('../models/proxy_host'); + +function omissions() { + return ['is_deleted']; +} + +const internalUpstream = { + + /** + * @param {Access} access + * @param {Object} data + * @returns {Promise} + */ + create: (access, data) => { + return access.can('upstreams:create', data) + .then(() => { + data.owner_user_id = access.token.getUserId(1); + return upstreamModel.query().insertAndFetch(data).then(utils.omitRow(omissions())); + }) + .then((row) => { + // Audit log + return internalAuditLog.add(access, { + action: 'created', + object_type: 'upstream', + object_id: row.id, + meta: data + }).then(() => row); + }); + }, + + /** + * @param {Access} access + * @param {Object} data + * @return {Promise} + */ + update: (access, data) => { + return access.can('upstreams:update', data.id) + .then(() => { + return internalUpstream.get(access, { id: data.id }); + }) + .then(row => { + if (row.id !== data.id) { + throw new error.InternalValidationError('Upstream could not be updated, IDs do not match: ' + row.id + ' !== ' + data.id); + } + + return upstreamModel.query().patchAndFetchById(row.id, data).then(utils.omitRow(omissions())); + }) + .then(row => { + // Audit log + return internalAuditLog.add(access, { + action: 'updated', + object_type: 'upstream', + object_id: row.id, + meta: data + }).then(() => row); + }) + .then(row => { + // Find all proxy hosts using this upstream and re-generate their configs + return proxyHostModel.query() + .where('upstream_id', row.id) + .andWhere('is_deleted', 0) + .withGraphFetched('[certificate,access_list,upstream]') // The fix is here: use withGraphFetched + .then(hosts => { + if (hosts && hosts.length) { + return internalNginx.bulkGenerateConfigs('proxy_host', hosts) + .then(internalNginx.reload) + .then(() => row); + } + return row; + }); + }); + }, + + /** + * @param {Access} access + * @param {Object} data + * @return {Promise} + */ + get: (access, data) => { + if (typeof data === 'undefined') { + data = {}; + } + + return access.can('upstreams:get', data.id) + .then(access_data => { + let query = upstreamModel + .query() + .where('is_deleted', 0) + .andWhere('id', data.id) + .allowGraph('[owner]') + .first(); + + if (access_data.permission_visibility !== 'all') { + query.andWhere('owner_user_id', access.token.getUserId(1)); + } + + if (typeof data.expand !== 'undefined' && data.expand !== null) { + query.withGraphFetched(`[${data.expand.join(', ')}]`); + } + + return query.then(utils.omitRow(omissions())); + }) + .then(row => { + if (!row || !row.id) { + throw new error.ItemNotFoundError(data.id); + } + return row; + }); + }, + + /** + * @param {Access} access + * @param {Object} data + * @returns {Promise} + */ + delete: (access, data) => { + return access.can('upstreams:delete', data.id) + .then(() => { + return internalUpstream.get(access, { id: data.id }); + }) + .then(row => { + if (!row || !row.id) { + throw new error.ItemNotFoundError(data.id); + } + + return upstreamModel.query() + .where('id', row.id) + .patch({ is_deleted: 1 }); + }) + .then(() => { + return internalAuditLog.add(access, { + action: 'deleted', + object_type: 'upstream', + object_id: data.id + }); + }) + .then(() => true); + }, + + /** + * @param {Access} access + * @returns {Promise} + */ + getAll: (access, expand, search_query) => { + return access.can('upstreams:list') + .then(access_data => { + let query = upstreamModel + .query() + .where('is_deleted', 0) + .allowGraph('[owner]') + .orderBy('name', 'ASC'); + + if (access_data.permission_visibility !== 'all') { + query.andWhere('owner_user_id', access.token.getUserId(1)); + } + + if (typeof search_query === 'string' && search_query) { + query.where('name', 'like', `%${search_query}%`); + } + + if (typeof expand !== 'undefined' && expand !== null) { + query.withGraphFetched(`[${expand.join(', ')}]`); + } + + return query.then(utils.omitRows(omissions())); + }); + } +}; + +module.exports = internalUpstream; diff --git a/backend/internal/user.js b/backend/internal/user.js index 742ab65d3..132680ebb 100644 --- a/backend/internal/user.js +++ b/backend/internal/user.js @@ -70,7 +70,8 @@ const internalUser = { dead_hosts: 'manage', streams: 'manage', access_lists: 'manage', - certificates: 'manage' + certificates: 'manage', + upstreams: 'manage' }) .then(() => { return internalUser.get(access, {id: user.id, expand: ['permissions']}); diff --git a/backend/lib/access.js b/backend/lib/access.js index 0e658a656..fc653373b 100644 --- a/backend/lib/access.js +++ b/backend/lib/access.js @@ -261,7 +261,8 @@ module.exports = function (token_string) { permission_dead_hosts: permissions.dead_hosts, permission_streams: permissions.streams, permission_access_lists: permissions.access_lists, - permission_certificates: permissions.certificates + permission_certificates: permissions.certificates, + permission_upstreams: permissions.upstreams } }; diff --git a/backend/lib/access/upstreams-create.json b/backend/lib/access/upstreams-create.json new file mode 100644 index 000000000..07eacd169 --- /dev/null +++ b/backend/lib/access/upstreams-create.json @@ -0,0 +1,28 @@ +{ + "anyOf": [ + { + "$ref": "roles#/definitions/admin" + }, + { + "type": "object", + "required": [ + "permission_upstreams", + "roles" + ], + "properties": { + "permission_upstreams": { + "$ref": "perms#/definitions/manage" + }, + "roles": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "user" + ] + } + } + } + } + ] +} diff --git a/backend/lib/access/upstreams-delete.json b/backend/lib/access/upstreams-delete.json new file mode 100644 index 000000000..07eacd169 --- /dev/null +++ b/backend/lib/access/upstreams-delete.json @@ -0,0 +1,28 @@ +{ + "anyOf": [ + { + "$ref": "roles#/definitions/admin" + }, + { + "type": "object", + "required": [ + "permission_upstreams", + "roles" + ], + "properties": { + "permission_upstreams": { + "$ref": "perms#/definitions/manage" + }, + "roles": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "user" + ] + } + } + } + } + ] +} diff --git a/backend/lib/access/upstreams-get.json b/backend/lib/access/upstreams-get.json new file mode 100644 index 000000000..d08b1e4b8 --- /dev/null +++ b/backend/lib/access/upstreams-get.json @@ -0,0 +1,28 @@ +{ + "anyOf": [ + { + "$ref": "roles#/definitions/admin" + }, + { + "type": "object", + "required": [ + "permission_upstreams", + "roles" + ], + "properties": { + "permission_upstreams": { + "$ref": "perms#/definitions/view" + }, + "roles": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "user" + ] + } + } + } + } + ] +} diff --git a/backend/lib/access/upstreams-list.json b/backend/lib/access/upstreams-list.json new file mode 100644 index 000000000..d08b1e4b8 --- /dev/null +++ b/backend/lib/access/upstreams-list.json @@ -0,0 +1,28 @@ +{ + "anyOf": [ + { + "$ref": "roles#/definitions/admin" + }, + { + "type": "object", + "required": [ + "permission_upstreams", + "roles" + ], + "properties": { + "permission_upstreams": { + "$ref": "perms#/definitions/view" + }, + "roles": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "user" + ] + } + } + } + } + ] +} diff --git a/backend/lib/access/upstreams-update.json b/backend/lib/access/upstreams-update.json new file mode 100644 index 000000000..07eacd169 --- /dev/null +++ b/backend/lib/access/upstreams-update.json @@ -0,0 +1,28 @@ +{ + "anyOf": [ + { + "$ref": "roles#/definitions/admin" + }, + { + "type": "object", + "required": [ + "permission_upstreams", + "roles" + ], + "properties": { + "permission_upstreams": { + "$ref": "perms#/definitions/manage" + }, + "roles": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "user" + ] + } + } + } + } + ] +} diff --git a/backend/migrations/20250825073649_upstreams.js b/backend/migrations/20250825073649_upstreams.js new file mode 100755 index 000000000..eb122a6c1 --- /dev/null +++ b/backend/migrations/20250825073649_upstreams.js @@ -0,0 +1,71 @@ +const migrate_name = 'upstreams'; +const logger = require('../logger').migrate; + +/** + * Migrate + * @see http://knexjs.org/#Schema + * @param {Object} knex + * @param {Promise} Promise + * @returns {Promise} + */ +exports.up = function (knex) { + logger.info('[' + migrate_name + '] Migrating Up...'); + + return knex.schema.createTable('upstream', (table) => { + table.increments().primary(); + table.dateTime('created_on').notNull(); + table.dateTime('modified_on').notNull(); + table.integer('owner_user_id').notNull().unsigned(); + table.integer('is_deleted').notNull().unsigned().defaultTo(0); + table.string('name').notNull(); + table.string('scheme').notNull().defaultTo('http'); + table.json('servers').notNull(); + table.json('meta').notNull(); + }) + .then(() => { + logger.info('[' + migrate_name + '] upstream Table created'); + return knex.schema.table('proxy_host', (table) => { + table.string('forward_host').nullable().alter(); + table.integer('forward_port').nullable().alter(); + table.integer('upstream_id').notNull().unsigned().defaultTo(0); + }); + }) + .then(() => { + logger.info('[' + migrate_name + '] proxy_host Table altered'); + return knex.schema.table('user_permission', (table) => { + table.string('upstreams').notNull().defaultTo('hidden'); + }); + }) + .then(() => { + logger.info('[' + migrate_name + '] user_permission Table altered'); + }); +}; + +/** + * Undo Migrate + * @param {Object} knex + * @param {Promise} Promise + * @returns {Promise} + */ +exports.down = function (knex) { + logger.info('[' + migrate_name + '] Migrating Down...'); + + return knex.schema.dropTable('upstream') + .then(() => { + logger.info('[' + migrate_name + '] upstream Table dropped'); + return knex.schema.table('proxy_host', (table) => { + table.string('forward_host').notNullable().alter(); + table.integer('forward_port').notNullable().alter(); + table.dropColumn('upstream_id'); + }); + }) + .then(() => { + logger.info('[' + migrate_name + '] proxy_host Table altered'); + return knex.schema.table('user_permission', (table) => { + table.dropColumn('upstreams'); + }); + }) + .then(() => { + logger.info('[' + migrate_name + '] user_permission Table altered'); + }); +}; \ No newline at end of file diff --git a/backend/models/proxy_host.js b/backend/models/proxy_host.js index 07aa5dd3c..ec97c011e 100644 --- a/backend/models/proxy_host.js +++ b/backend/models/proxy_host.js @@ -7,6 +7,7 @@ const Model = require('objection').Model; const User = require('./user'); const AccessList = require('./access_list'); const Certificate = require('./certificate'); +const Upstream = require('./upstream'); const now = require('./now_helper'); Model.knex(db); @@ -106,6 +107,17 @@ class ProxyHost extends Model { modify: function (qb) { qb.where('certificate.is_deleted', 0); } + }, + upstream: { + relation: Model.HasOneRelation, + modelClass: Upstream, + join: { + from: 'proxy_host.upstream_id', + to: 'upstream.id' + }, + modify: function (qb) { + qb.where('upstream.is_deleted', 0); + } } }; } diff --git a/backend/models/upstream.js b/backend/models/upstream.js new file mode 100644 index 000000000..a4e6d15be --- /dev/null +++ b/backend/models/upstream.js @@ -0,0 +1,70 @@ +const db = require('../db'); +const helpers = require('../lib/helpers'); +const Model = require('objection').Model; +const User = require('./user'); +const now = require('./now_helper'); + +Model.knex(db); + +const boolFields = [ + 'is_deleted', +]; + +class Upstream extends Model { + $beforeInsert() { + this.created_on = now(); + this.modified_on = now(); + + if (typeof this.meta === 'undefined') { + this.meta = {}; + } + + if (typeof this.servers === 'undefined') { + this.servers = []; + } + } + + $beforeUpdate() { + this.modified_on = now(); + } + + $parseDatabaseJson(json) { + json = super.$parseDatabaseJson(json); + return helpers.convertIntFieldsToBool(json, boolFields); + } + + $formatDatabaseJson(json) { + json = helpers.convertBoolFieldsToInt(json, boolFields); + return super.$formatDatabaseJson(json); + } + + static get name() { + return 'Upstream'; + } + + static get tableName() { + return 'upstream'; + } + + static get jsonAttributes() { + return ['servers', 'meta']; + } + + static get relationMappings() { + return { + owner: { + relation: Model.HasOneRelation, + modelClass: User, + join: { + from: 'upstream.owner_user_id', + to: 'user.id' + }, + modify: function (qb) { + qb.where('user.is_deleted', 0); + } + } + }; + } +} + +module.exports = Upstream; diff --git a/backend/routes/main.js b/backend/routes/main.js index b97096d0e..83c83028e 100644 --- a/backend/routes/main.js +++ b/backend/routes/main.js @@ -37,6 +37,7 @@ router.use('/nginx/dead-hosts', require('./nginx/dead_hosts')); router.use('/nginx/streams', require('./nginx/streams')); router.use('/nginx/access-lists', require('./nginx/access_lists')); router.use('/nginx/certificates', require('./nginx/certificates')); +router.use('/nginx/upstreams', require('./nginx/upstreams')); /** * API 404 for all other routes diff --git a/backend/routes/nginx/upstreams.js b/backend/routes/nginx/upstreams.js new file mode 100644 index 000000000..1c49d2a01 --- /dev/null +++ b/backend/routes/nginx/upstreams.js @@ -0,0 +1,73 @@ +const express = require('express'); +const validator = require('../../lib/validator'); +const jwtdecode = require('../../lib/express/jwt-decode'); +const internalUpstream = require('../../internal/upstream'); + +let router = express.Router({ + caseSensitive: true, + strict: true, + mergeParams: true +}); + +router + .route('/') + .options((_, res) => { + res.sendStatus(204); + }) + .all(jwtdecode()) + .get((req, res, next) => { + validator({ + additionalProperties: false, + properties: { + expand: { $ref: 'common#/properties/expand' }, + query: { $ref: 'common#/properties/query' } + } + }, { + expand: (typeof req.query.expand === 'string' ? req.query.expand.split(',') : null), + query: (typeof req.query.query === 'string' ? req.query.query : null) + }) + .then(data => internalUpstream.getAll(res.locals.access, data.expand, data.query)) + .then(rows => res.status(200).send(rows)) + .catch(next); + }) + .post((req, res, next) => { + internalUpstream.create(res.locals.access, req.body) + .then(result => res.status(201).send(result)) + .catch(next); + }); + +router + .route('/:id') + .options((_, res) => { + res.sendStatus(204); + }) + .all(jwtdecode()) + .get((req, res, next) => { + validator({ + required: ['id'], + additionalProperties: false, + properties: { + id: { $ref: 'common#/properties/id' }, + expand: { $ref: 'common#/properties/expand' } + } + }, { + id: req.params.id, + expand: (typeof req.query.expand === 'string' ? req.query.expand.split(',') : null) + }) + .then(data => internalUpstream.get(res.locals.access, { id: parseInt(data.id, 10), expand: data.expand })) + .then(row => res.status(200).send(row)) + .catch(next); + }) + .put((req, res, next) => { + req.body.id = parseInt(req.params.id, 10); + internalUpstream.update(res.locals.access, req.body) + .then(result => res.status(200).send(result)) + .catch(next); + }) + .delete((req, res, next) => { + internalUpstream.delete(res.locals.access, { id: parseInt(req.params.id, 10) }) + .then(result => res.status(200).send(result)) + .catch(next); + }); + +module.exports = router; diff --git a/backend/schema/components/proxy-host-object.json b/backend/schema/components/proxy-host-object.json index e9dcacb5e..b74b2502b 100644 --- a/backend/schema/components/proxy-host-object.json +++ b/backend/schema/components/proxy-host-object.json @@ -22,7 +22,8 @@ "enabled", "locations", "hsts_enabled", - "hsts_subdomains" + "hsts_subdomains", + "upstream_id" ], "additionalProperties": false, "properties": { @@ -54,6 +55,11 @@ "access_list_id": { "$ref": "../common.json#/properties/access_list_id" }, + "upstream_id": { + "description": "Upstream ID", + "type": "integer", + "minimum": 0 + }, "certificate_id": { "$ref": "../common.json#/properties/certificate_id" }, @@ -148,6 +154,16 @@ "$ref": "./access-list-object.json" } ] + }, + "upstream": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "./upstream-object.json" + } + ] } } } diff --git a/backend/schema/components/upstream-object.json b/backend/schema/components/upstream-object.json new file mode 100644 index 000000000..549c1184d --- /dev/null +++ b/backend/schema/components/upstream-object.json @@ -0,0 +1,58 @@ +{ + "type": "object", + "description": "Upstream object", + "required": [ + "id", + "created_on", + "modified_on", + "owner_user_id", + "name", + "scheme", + "servers", + "meta" + ], + "additionalProperties": false, + "properties": { + "id": { + "$ref": "../common.json#/properties/id" + }, + "created_on": { + "$ref": "../common.json#/properties/created_on" + }, + "modified_on": { + "$ref": "../common.json#/properties/modified_on" + }, + "owner_user_id": { + "$ref": "../common.json#/properties/user_id" + }, + "name": { + "type": "string", + "minLength": 1 + }, + "scheme": { + "type": "string", + "enum": ["http", "https"] + }, + "servers": { + "type": "array", + "items": { + "type": "object", + "required": ["host", "port"], + "properties": { + "host": { + "type": "string" + }, + "port": { + "type": "integer" + }, + "weight": { + "type": "string" + } + } + } + }, + "meta": { + "type": "object" + } + } +} diff --git a/backend/schema/paths/nginx/proxy-hosts/hostID/put.json b/backend/schema/paths/nginx/proxy-hosts/hostID/put.json index 5cab6e752..c2bd66aa3 100644 --- a/backend/schema/paths/nginx/proxy-hosts/hostID/put.json +++ b/backend/schema/paths/nginx/proxy-hosts/hostID/put.json @@ -44,6 +44,9 @@ "certificate_id": { "$ref": "../../../../components/proxy-host-object.json#/properties/certificate_id" }, + "upstream_id": { + "$ref": "../../../../components/proxy-host-object.json#/properties/upstream_id" + }, "ssl_forced": { "$ref": "../../../../components/proxy-host-object.json#/properties/ssl_forced" }, @@ -129,7 +132,8 @@ "roles": ["admin"] }, "certificate": null, - "access_list": null + "access_list": null, + "upstream_id": 0 } } }, diff --git a/backend/schema/paths/nginx/proxy-hosts/post.json b/backend/schema/paths/nginx/proxy-hosts/post.json index 85455fb6b..84c6b8b67 100644 --- a/backend/schema/paths/nginx/proxy-hosts/post.json +++ b/backend/schema/paths/nginx/proxy-hosts/post.json @@ -15,7 +15,7 @@ "schema": { "type": "object", "additionalProperties": false, - "required": ["domain_names", "forward_scheme", "forward_host", "forward_port"], + "required": ["domain_names"], "properties": { "domain_names": { "$ref": "../../../components/proxy-host-object.json#/properties/domain_names" @@ -32,6 +32,9 @@ "certificate_id": { "$ref": "../../../components/proxy-host-object.json#/properties/certificate_id" }, + "upstream_id": { + "$ref": "../../../components/proxy-host-object.json#/properties/upstream_id" + }, "ssl_forced": { "$ref": "../../../components/proxy-host-object.json#/properties/ssl_forced" }, @@ -67,8 +70,33 @@ }, "locations": { "$ref": "../../../components/proxy-host-object.json#/properties/locations" + }, + "forward_to_type": { + "type": "string", + "enum": [ + "host", + "upstream" + ] } - } + }, + "oneOf": [ + { + "required": [ + "forward_host", + "forward_port" + ] + }, + { + "required": [ + "upstream_id" + ], + "properties": { + "upstream_id": { + "minimum": 1 + } + } + } + ] } } } diff --git a/backend/setup.js b/backend/setup.js index 29208a0da..010b2bd7f 100644 --- a/backend/setup.js +++ b/backend/setup.js @@ -57,6 +57,7 @@ const setupDefaultUser = () => { streams: 'manage', access_lists: 'manage', certificates: 'manage', + upstreams: 'manage' }); }); }) diff --git a/backend/templates/proxy_host.conf b/backend/templates/proxy_host.conf index d23ca46fa..087341ce0 100644 --- a/backend/templates/proxy_host.conf +++ b/backend/templates/proxy_host.conf @@ -4,10 +4,25 @@ {% include "_hsts_map.conf" %} +{% if upstream and upstream_id > 0 -%} +# UPSTREAM: {{ upstream.name }} +upstream upstream-{{ id }} { +{% for server in upstream.servers -%} + server {{ server.host }}:{{ server.port }}{{ server.weight | default: "" | prepend: " " }}; +{% endfor -%} +} +{% endif -%} + server { +{% if upstream and upstream_id > 0 -%} + set $forward_scheme {{ upstream.scheme }}; + set $server "upstream-{{ id }}"; + set $port ""; +{% else -%} set $forward_scheme {{ forward_scheme }}; set $server "{{ forward_host }}"; set $port {{ forward_port }}; +{% endif -%} {% include "_listen.conf" %} {% include "_certificates.conf" %} @@ -43,7 +58,12 @@ proxy_http_version 1.1; {% endif %} # Proxy! - include conf.d/include/proxy.conf; + {% if upstream and upstream_id > 0 -%} + include conf.d/include/loadbalancer-proxy.conf; + {% else -%} + include conf.d/include/proxy.conf; + {% endif -%} + } {% endif %} diff --git a/docker/rootfs/etc/nginx/conf.d/include/loadbalancer-proxy.conf b/docker/rootfs/etc/nginx/conf.d/include/loadbalancer-proxy.conf new file mode 100644 index 000000000..9e3fde2ad --- /dev/null +++ b/docker/rootfs/etc/nginx/conf.d/include/loadbalancer-proxy.conf @@ -0,0 +1,8 @@ +add_header X-Served-By $host; +proxy_set_header Host $host; +proxy_set_header X-Forwarded-Scheme $scheme; +proxy_set_header X-Forwarded-Proto $scheme; +proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; +proxy_set_header X-Real-IP $remote_addr; +proxy_pass $forward_scheme://$server$request_uri; + diff --git a/frontend/js/app/api.js b/frontend/js/app/api.js index 6e33a6dca..c64af9d42 100644 --- a/frontend/js/app/api.js +++ b/frontend/js/app/api.js @@ -702,6 +702,43 @@ module.exports = { download: function (id) { return DownloadFile('get', "nginx/certificates/" + id + "/download", "certificate.zip") } + }, + + Upstreams: { + /** + * @param {Array} [expand] + * @param {String} [query] + * @returns {Promise} + */ + getAll: function (expand, query) { + return getAllObjects('nginx/upstreams', expand, query); + }, + + /** + * @param {Object} data + */ + create: function (data) { + return fetch('post', 'nginx/upstreams', data); + }, + + /** + * @param {Object} data + * @param {Number} data.id + * @returns {Promise} + */ + update: function (data) { + let id = data.id; + delete data.id; + return fetch('put', 'nginx/upstreams/' + id, data); + }, + + /** + * @param {Number} id + * @returns {Promise} + */ + delete: function (id) { + return fetch('delete', 'nginx/upstreams/' + id); + } } }, diff --git a/frontend/js/app/controller.js b/frontend/js/app/controller.js index ebddd7807..489d9e236 100644 --- a/frontend/js/app/controller.js +++ b/frontend/js/app/controller.js @@ -368,9 +368,48 @@ module.exports = { showNginxCertificateTestReachability: function (model) { if (Cache.User.isAdmin() || Cache.User.canManage('certificates')) { require(['./main', './nginx/certificates/test'], function (App, View) { - App.UI.showModalDialog(new View({model: model})); + App.UI.showModalDialog(new View({model: model})); }); - } + } + }, + + /** + * Nginx Upstreams + */ + showNginxUpstreams: function () { + if (Cache.User.isAdmin() || Cache.User.canView('upstreams')) { + const controller = this; + require(['./main', './nginx/upstreams/main'], (App, View) => { + controller.navigate('/nginx/upstreams'); + App.UI.showAppContent(new View()); + }); + } + }, + + /** + * Nginx Upstream Form + * + * @param [model] + */ + showNginxUpstreamForm: function (model) { + if (Cache.User.isAdmin() || Cache.User.canManage('upstreams')) { + require(['./main', './nginx/upstreams/form'], function (App, View) { + App.UI.showModalDialog(new View({model: model})); + }); + } + }, + + /** + * Upstream Delete Confirm + * + * @param model + */ + showNginxUpstreamDeleteConfirm: function (model) { + if (Cache.User.isAdmin() || Cache.User.canManage('upstreams')) { + require(['./main', './nginx/upstreams/delete'], function (App, View) { + App.UI.showModalDialog(new View({model: model})); + }); + } }, /** diff --git a/frontend/js/app/nginx/proxy/form.ejs b/frontend/js/app/nginx/proxy/form.ejs index 8e7a2a2df..a1e1f5ee8 100644 --- a/frontend/js/app/nginx/proxy/form.ejs +++ b/frontend/js/app/nginx/proxy/form.ejs @@ -33,7 +33,16 @@ -
+
+
+ + +
+
+
-
+
-
+
+
+
+ + +
+
-
<%- forward_scheme %>://<%- forward_host %>:<%- forward_port %>
+ <% if (upstream && upstream_id > 0) { + let popover_content = upstream.servers.map(function(server) { return upstream.scheme + '://' + server.host + ':' + server.port; }).join('
'); + %> +
+ <%- upstream.name %> +
+ <% } else { %> +
<%- forward_scheme %>://<%- forward_host %>:<%- forward_port %>
+ <% } %>
<%- certificate && certificate_id ? i18n('ssl', certificate.provider) : i18n('ssl', 'none') %>
diff --git a/frontend/js/app/nginx/proxy/list/item.js b/frontend/js/app/nginx/proxy/list/item.js index 37d199b4a..4007b96a9 100644 --- a/frontend/js/app/nginx/proxy/list/item.js +++ b/frontend/js/app/nginx/proxy/list/item.js @@ -10,7 +10,8 @@ module.exports = Mn.View.extend({ able: 'a.able', edit: 'a.edit', delete: 'a.delete', - host_link: '.host-link' + host_link: '.host-link', + popover: '[data-toggle="popover"]' }, events: { @@ -55,6 +56,12 @@ module.exports = Mn.View.extend({ } }, + onRender: function() { + this.ui.popover.popover({ + html: true + }); + }, + initialize: function () { this.listenTo(this.model, 'change', this.render); } diff --git a/frontend/js/app/nginx/proxy/main.js b/frontend/js/app/nginx/proxy/main.js index baf671013..a91d029e2 100644 --- a/frontend/js/app/nginx/proxy/main.js +++ b/frontend/js/app/nginx/proxy/main.js @@ -73,7 +73,7 @@ module.exports = Mn.View.extend({ e.preventDefault(); let query = this.ui.query.val(); - this.fetch(['owner', 'access_list', 'certificate'], query) + this.fetch(['owner', 'access_list', 'certificate', 'upstream'], query) .then(response => this.showData(response)) .catch(err => { this.showError(err); @@ -88,7 +88,7 @@ module.exports = Mn.View.extend({ onRender: function () { let view = this; - view.fetch(['owner', 'access_list', 'certificate']) + view.fetch(['owner', 'access_list', 'certificate', 'upstream']) .then(response => { if (!view.isDestroyed()) { if (response && response.length) { diff --git a/frontend/js/app/nginx/proxy/upstream-list-item.ejs b/frontend/js/app/nginx/proxy/upstream-list-item.ejs new file mode 100644 index 000000000..6edfe3261 --- /dev/null +++ b/frontend/js/app/nginx/proxy/upstream-list-item.ejs @@ -0,0 +1,8 @@ +
+
+ <%- name %> +
+ + <%- scheme %> – <%- servers.length %> Server(s) – Created: <%- formatDbDate(created_on, 'Do MMM YYYY, h:mm a') %> + +
\ No newline at end of file diff --git a/frontend/js/app/nginx/upstreams/delete.ejs b/frontend/js/app/nginx/upstreams/delete.ejs new file mode 100644 index 000000000..581864bdc --- /dev/null +++ b/frontend/js/app/nginx/upstreams/delete.ejs @@ -0,0 +1,20 @@ + diff --git a/frontend/js/app/nginx/upstreams/delete.js b/frontend/js/app/nginx/upstreams/delete.js new file mode 100644 index 000000000..a0bf2c398 --- /dev/null +++ b/frontend/js/app/nginx/upstreams/delete.js @@ -0,0 +1,32 @@ +const Mn = require('backbone.marionette'); +const App = require('../../main'); +const template = require('./delete.ejs'); + +module.exports = Mn.View.extend({ + template: template, + className: 'modal-dialog', + + ui: { + form: 'form', + buttons: '.modal-footer button', + cancel: 'button.cancel', + save: 'button.save' + }, + + events: { + 'click @ui.save': function (e) { + e.preventDefault(); + this.ui.buttons.prop('disabled', true).addClass('btn-disabled'); + + App.Api.Nginx.Upstreams.delete(this.model.get('id')) + .then(() => { + App.Controller.showNginxUpstreams(); + App.UI.closeModal(); + }) + .catch(err => { + alert(err.message); + this.ui.buttons.prop('disabled', false).removeClass('btn-disabled'); + }); + } + } +}); diff --git a/frontend/js/app/nginx/upstreams/form.ejs b/frontend/js/app/nginx/upstreams/form.ejs new file mode 100644 index 000000000..2835e658d --- /dev/null +++ b/frontend/js/app/nginx/upstreams/form.ejs @@ -0,0 +1,49 @@ + diff --git a/frontend/js/app/nginx/upstreams/form.js b/frontend/js/app/nginx/upstreams/form.js new file mode 100644 index 000000000..ca0498208 --- /dev/null +++ b/frontend/js/app/nginx/upstreams/form.js @@ -0,0 +1,103 @@ +const Mn = require('backbone.marionette'); +const App = require('../../main'); +const UpstreamModel = require('../../../models/upstream'); +const UpstreamServerModel = require('../../../models/upstream-server'); +const template = require('./form.ejs'); +const ServerView = require('./form/form-server'); +require('jquery-serializejson'); + +const ServersView = Mn.CollectionView.extend({ + childView: ServerView +}); + +module.exports = Mn.View.extend({ + template: template, + className: 'modal-dialog modal-lg', + serversCollection: new UpstreamServerModel.Collection(), + + ui: { + form: 'form', + buttons: '.modal-footer button', + cancel: 'button.cancel', + save: 'button.save', + add_server: 'button.add-server', + servers_region: '.servers' + }, + + regions: { + servers_region: '@ui.servers_region' + }, + + events: { + 'click @ui.save': function (e) { + e.preventDefault(); + if (!this.ui.form[0].checkValidity()) { + $('').hide().appendTo(this.ui.form).click().remove(); + return; + } + + let view = this; + let data = this.ui.form.serializeJSON(); + + // Convert servers object to array + if (data.servers) { + data.servers = Object.values(data.servers); + } else { + data.servers = []; + } + + if (data.servers.length === 0) { + alert('You must specify at least 1 server.'); + return; + } + + let method = App.Api.Nginx.Upstreams.create; + let is_new = true; + + if (this.model.get('id')) { + is_new = false; + method = App.Api.Nginx.Upstreams.update; + data.id = this.model.get('id'); + } + + this.ui.buttons.prop('disabled', true).addClass('btn-disabled'); + + method(data) + .then(result => { + view.model.set(result); + App.UI.closeModal(() => { + if (is_new) { + App.Controller.showNginxUpstreams(); + } + }); + }) + .catch(err => { + alert(err.message); + this.ui.buttons.prop('disabled', false).removeClass('btn-disabled'); + }); + }, + + 'click @ui.add_server': function(e) { + e.preventDefault(); + this.serversCollection.add(new UpstreamServerModel.Model()); + } + }, + + onRender: function () { + this.showChildView('servers_region', new ServersView({ + collection: this.serversCollection + })); + }, + + initialize: function (options) { + if (typeof options.model === 'undefined' || !options.model) { + this.model = new UpstreamModel.Model(); + } + + this.serversCollection = new UpstreamServerModel.Collection(this.model.get('servers')); + + if (this.serversCollection.length === 0) { + this.serversCollection.add(new UpstreamServerModel.Model()); + } + } +}); diff --git a/frontend/js/app/nginx/upstreams/form/form-server.ejs b/frontend/js/app/nginx/upstreams/form/form-server.ejs new file mode 100644 index 000000000..5c754bc73 --- /dev/null +++ b/frontend/js/app/nginx/upstreams/form/form-server.ejs @@ -0,0 +1,18 @@ +
+
+ +
+
+
+
+ +
+
+
+
+ +
+
+ diff --git a/frontend/js/app/nginx/upstreams/form/form-server.js b/frontend/js/app/nginx/upstreams/form/form-server.js new file mode 100644 index 000000000..657f1f1ed --- /dev/null +++ b/frontend/js/app/nginx/upstreams/form/form-server.js @@ -0,0 +1,26 @@ +const Mn = require('backbone.marionette'); +const template = require('./form-server.ejs'); + +module.exports = Mn.View.extend({ + template: template, + className: 'row', + ui: { + delete: 'a.delete' + }, + + events: { + 'change input': function(e) { + this.model.set(e.target.name.split('[').pop().slice(0, -1), e.target.value); + }, + 'click @ui.delete': function(e) { + e.preventDefault(); + this.model.destroy(); + } + }, + + templateContext: function() { + return { + cid: this.model.cid + }; + } +}); diff --git a/frontend/js/app/nginx/upstreams/list/item.ejs b/frontend/js/app/nginx/upstreams/list/item.ejs new file mode 100644 index 000000000..f49addea5 --- /dev/null +++ b/frontend/js/app/nginx/upstreams/list/item.ejs @@ -0,0 +1,30 @@ + +
+ +
+ + +
<%- name %>
+
+ Created: <%- formatDbDate(created_on, 'Do MMMM YYYY') %> +
+ + + <%- scheme %> + + + <%- servers.length %> Servers + +<% if (canManage) { %> + + + +<% } %> diff --git a/frontend/js/app/nginx/upstreams/list/item.js b/frontend/js/app/nginx/upstreams/list/item.js new file mode 100644 index 000000000..9dea2a4e6 --- /dev/null +++ b/frontend/js/app/nginx/upstreams/list/item.js @@ -0,0 +1,33 @@ +const Mn = require('backbone.marionette'); +const App = require('../../../main'); +const template = require('./item.ejs'); + +module.exports = Mn.View.extend({ + template: template, + tagName: 'tr', + + ui: { + edit: 'a.edit', + delete: 'a.delete' + }, + + events: { + 'click @ui.edit': function (e) { + e.preventDefault(); + App.Controller.showNginxUpstreamForm(this.model); + }, + + 'click @ui.delete': function (e) { + e.preventDefault(); + App.Controller.showNginxUpstreamDeleteConfirm(this.model); + } + }, + + templateContext: { + canManage: App.Cache.User.canManage('upstreams') + }, + + initialize: function () { + this.listenTo(this.model, 'change', this.render); + } +}); diff --git a/frontend/js/app/nginx/upstreams/list/main.ejs b/frontend/js/app/nginx/upstreams/list/main.ejs new file mode 100644 index 000000000..1e28f96d3 --- /dev/null +++ b/frontend/js/app/nginx/upstreams/list/main.ejs @@ -0,0 +1,12 @@ + +   + Name + Scheme + Servers + <% if (canManage) { %> +   + <% } %> + + + + diff --git a/frontend/js/app/nginx/upstreams/list/main.js b/frontend/js/app/nginx/upstreams/list/main.js new file mode 100644 index 000000000..fcdcd5f54 --- /dev/null +++ b/frontend/js/app/nginx/upstreams/list/main.js @@ -0,0 +1,32 @@ +const Mn = require('backbone.marionette'); +const App = require('../../../main'); +const ItemView = require('./item'); +const template = require('./main.ejs'); + +const TableBody = Mn.CollectionView.extend({ + tagName: 'tbody', + childView: ItemView +}); + +module.exports = Mn.View.extend({ + tagName: 'table', + className: 'table table-hover table-outline table-vcenter card-table', + template: template, + + regions: { + body: { + el: 'tbody', + replaceElement: true + } + }, + + templateContext: { + canManage: App.Cache.User.canManage('upstreams') + }, + + onRender: function () { + this.showChildView('body', new TableBody({ + collection: this.collection + })); + } +}); diff --git a/frontend/js/app/nginx/upstreams/main.ejs b/frontend/js/app/nginx/upstreams/main.ejs new file mode 100644 index 000000000..d8e9f7b72 --- /dev/null +++ b/frontend/js/app/nginx/upstreams/main.ejs @@ -0,0 +1,28 @@ +
+
+
+

Upstreams

+
+ + + <% if (showAddButton) { %> + Add Upstream + <% } %> +
+
+
+
+
+
+ +
+
+
+
diff --git a/frontend/js/app/nginx/upstreams/main.js b/frontend/js/app/nginx/upstreams/main.js new file mode 100644 index 000000000..6aad1b32d --- /dev/null +++ b/frontend/js/app/nginx/upstreams/main.js @@ -0,0 +1,104 @@ +const Mn = require('backbone.marionette'); +const App = require('../../main'); +const UpstreamModel = require('../../../models/upstream'); +const ListView = require('./list/main'); +const ErrorView = require('../../error/main'); +const EmptyView = require('../../empty/main'); +const template = require('./main.ejs'); + +module.exports = Mn.View.extend({ + id: 'nginx-upstreams', + template: template, + + ui: { + list_region: '.list-region', + add: '.add-item', + help: '.help', + dimmer: '.dimmer', + search: '.search-form', + query: 'input[name="source-query"]' + }, + + fetch: App.Api.Nginx.Upstreams.getAll, + + showData: function(response) { + this.showChildView('list_region', new ListView({ + collection: new UpstreamModel.Collection(response) + })); + }, + + showError: function(err) { + this.showChildView('list_region', new ErrorView({ + code: err.code, + message: err.message, + retry: function () { + App.Controller.showNginxUpstreams(); + } + })); + console.error(err); + }, + + showEmpty: function() { + let manage = App.Cache.User.canManage('upstreams'); + this.showChildView('list_region', new EmptyView({ + title: 'No Upstreams Found', + subtitle: 'You can create upstreams to load balance your services.', + link: manage ? 'Add Upstream' : null, + btn_color: 'green', + permission: 'upstreams', + action: function () { + App.Controller.showNginxUpstreamForm(); + } + })); + }, + + regions: { + list_region: '@ui.list_region' + }, + + events: { + 'click @ui.add': function (e) { + e.preventDefault(); + App.Controller.showNginxUpstreamForm(); + }, + + 'click @ui.help': function (e) { + e.preventDefault(); + App.Controller.showHelp('Upstreams', 'Upstreams allow you to define a pool of backend servers. Proxy hosts can then use these upstreams to load balance traffic across multiple servers.'); + }, + + 'submit @ui.search': function (e) { + e.preventDefault(); + let query = this.ui.query.val(); + this.fetch(['owner'], query) + .then(response => this.showData(response)) + .catch(err => { + this.showError(err); + }); + } + }, + + templateContext: { + showAddButton: App.Cache.User.canManage('upstreams') + }, + + onRender: function () { + let view = this; + view.fetch(['owner']) + .then(response => { + if (!view.isDestroyed()) { + if (response && response.length) { + view.showData(response); + } else { + view.showEmpty(); + } + } + }) + .catch(err => { + view.showError(err); + }) + .then(() => { + view.ui.dimmer.removeClass('active'); + }); + } +}); diff --git a/frontend/js/app/router.js b/frontend/js/app/router.js index a036bfc57..3829c5288 100644 --- a/frontend/js/app/router.js +++ b/frontend/js/app/router.js @@ -12,6 +12,7 @@ module.exports = AppRouter.default.extend({ 'nginx/stream': 'showNginxStream', 'nginx/access': 'showNginxAccess', 'nginx/certificates': 'showNginxCertificates', + 'nginx/upstreams': 'showNginxUpstreams', 'audit-log': 'showAuditLog', 'settings': 'showSettings', '*default': 'showDashboard' diff --git a/frontend/js/app/ui/menu/main.ejs b/frontend/js/app/ui/menu/main.ejs index 671b4e3be..099f9dde8 100644 --- a/frontend/js/app/ui/menu/main.ejs +++ b/frontend/js/app/ui/menu/main.ejs @@ -25,6 +25,9 @@ <% } %>
+ <% if (canShow('access_lists')) { %>