Compare commits

..

45 Commits

Author SHA1 Message Date
Julian Gassner
da22e0777e Fixed a bug that prevented the mfa to be enabled 2025-02-06 17:01:46 +00:00
Julian Gassner
0bfd2f901d Add possibility to remove mfa 2025-02-06 16:47:56 +00:00
Julian Gassner
6228a54ecf Restore yarn.lock 2025-02-05 22:38:30 +00:00
Julian Gassner
d20e15125e Merge branch 'NginxProxyManager:develop' into feature/add-multi-factor-authentication 2025-02-05 23:16:28 +01:00
jc21
0d5d2b1b7c Merge pull request #4283 from badkeyy/feature/show-active-host-in-cert-list
SSL Certificates: Show if cert is in use on host
2025-02-06 07:43:12 +10:00
Julian Gassner
34194e65d2 Update messages 2025-02-05 16:48:57 +00:00
jc21
3a01b2c84f Merge pull request #4334 from nwagenmakers/mijn-host-patch
All checks were successful
Close stale issues and PRs / stale (push) Successful in 4s
Update certbot-dns-plugins.json (mijn-host)
2025-02-05 20:36:06 +10:00
jc21
e1c84a5c10 Merge pull request #4338 from Sander0542/fix/token-expires-type
Fix type for token.expires
2025-02-05 20:35:33 +10:00
jc21
c56c95a59a Merge pull request #4344 from NginxProxyManager/stream-ssl
SSL for Streams - 2025
2025-02-05 18:22:51 +10:00
Julian Gassner
5d2a76adfe Merge branch 'feature/add-multi-factor-authentication' of https://github.com/badkeyy/nginx-proxy-manager into feature/add-multi-factor-authentication 2025-02-05 07:10:35 +00:00
Julian Gassner
33906bfcdc Remove package-lock 2025-02-05 07:10:34 +00:00
Julian Gassner
6e51d819fa Merge branch 'develop' into feature/add-multi-factor-authentication 2025-02-05 08:08:51 +01:00
Julian Gassner
8aa173a732 Finish MFA implementation 2025-02-05 07:05:15 +00:00
Jamie Curnow
6a60627833 Cypress test for Streams
and updated cypress + packages
2025-02-05 16:02:17 +10:00
Jamie Curnow
b4793d3c16 Adds testssl.sh and mkcert to cypress stack 2025-02-05 08:10:11 +10:00
Jamie Curnow
68a7803513 Fix api schema after merging latest changes 2025-02-04 17:55:28 +10:00
jbowring
2657af97cf Fix stream update not persisting 2025-02-04 17:14:07 +10:00
jbowring
4452f014b9 Fix whitespace in nginx stream config 2025-02-04 17:14:07 +10:00
jbowring
cd80cc8e4d Add certificate to streams database model 2025-02-04 17:14:04 +10:00
jbowring
ee4250d770 Add SSL column to streams table UI 2025-02-04 17:12:05 +10:00
jbowring
3dbc70faa6 Add SSL tab to stream UI 2025-02-04 17:12:04 +10:00
jbowring
3091c21cae Add SSL certificate to TCP streams if certificate in database 2025-02-04 17:12:04 +10:00
Sander Jochems
57cd2a1919 Fix type for token.expires 2025-02-03 21:47:41 +01:00
nwagenmakers
ad5936c530 Update certbot-dns-plugins.json (mijn-host)
Updated credentials hint/text in mijn-host plugin entry
2025-02-01 13:10:53 +01:00
jc21
498109addb Merge pull request #4310 from NginxProxyManager/dependabot/npm_and_yarn/docs/vite-5.4.14
All checks were successful
Close stale issues and PRs / stale (push) Successful in 3s
Bump vite from 5.4.8 to 5.4.14 in /docs
2025-01-28 18:08:46 +10:00
jc21
3f3aacd7ec Merge pull request #4274 from Dim145/develop
[Postgres] fix error in access_list get
2025-01-28 14:03:07 +10:00
dependabot[bot]
bb4ecf812d Bump vite from 5.4.8 to 5.4.14 in /docs
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 5.4.8 to 5.4.14.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v5.4.14/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v5.4.14/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-01-22 07:09:04 +00:00
Julian Gassner
35938db24b Added MFA to model and internal user 2025-01-15 14:33:11 +00:00
Julian Gassner
aedaaa18e0 Fix whitespace 2025-01-10 05:20:28 +01:00
Julian Gassner
080bd0b749 Added status of certificates to the certificate list and show on which domain names the certificates are in use 2025-01-10 05:15:22 +01:00
Julian Gassner
69f7920675 Remove console.logs 2025-01-09 23:04:13 +00:00
Julian Gassner
45fc63875c Merge branch 'feature/show-active-host-in-cert-list' of https://github.com/badkeyy/nginx-proxy-manager into feature/show-active-host-in-cert-list 2025-01-09 22:49:21 +00:00
Julian Gassner
f3fee7d886 Merge 2025-01-09 22:48:18 +00:00
Julian Gassner
50f7bfc726 Add dead_hosts and redirection_hosts 2025-01-09 22:47:08 +00:00
Julian Gassner
8fb9d9fec6 Removed whitespace 2025-01-09 18:32:45 +01:00
Julian Gassner
cea9a17218 Retrigger build 2025-01-09 18:07:50 +01:00
Julian Gassner
6bbe7d4cc4 Fix alignment 2025-01-08 17:48:09 +01:00
Julian Gassner
c6ab315165 Add status indicator to certificates and show active domain names 2025-01-08 05:43:10 +01:00
Jamie Curnow
9687e9e450 Use previous version of powerdns image, newer version is broken
All checks were successful
Close stale issues and PRs / stale (push) Successful in 3s
2025-01-07 10:30:08 +10:00
Jamie Curnow
5a234bb88c Fix incorrect test folder in ci results 2025-01-07 08:13:04 +10:00
jc21
4de4b65036 Merge pull request #4252 from GergelyGombai/develop
Add Gcore DNS Provider
2025-01-07 07:54:44 +10:00
dim145
f1c97c7c36 fix: add missing group_by clause for access_list get 2025-01-03 00:39:29 +01:00
ComradeBlin
73110d5e1e Update Gcore apikey format
I managed to mis-write the format in my previous commit
2024-12-22 01:44:52 +01:00
ComradeBlin
356b98bf7e Add Gcore DNS Provider 2024-12-22 01:02:47 +01:00
Julian Gassner
2c1595756d Add hosts to cert list 2024-12-18 18:10:20 +01:00
58 changed files with 1521 additions and 332 deletions

6
Jenkinsfile vendored
View File

@@ -128,7 +128,7 @@ pipeline {
sh 'docker-compose down --remove-orphans --volumes -t 30 || true' sh 'docker-compose down --remove-orphans --volumes -t 30 || true'
} }
unstable { unstable {
dir(path: 'testing/results') { dir(path: 'test/results') {
archiveArtifacts(allowEmptyArchive: true, artifacts: '**/*', excludes: '**/*.xml') archiveArtifacts(allowEmptyArchive: true, artifacts: '**/*', excludes: '**/*.xml')
} }
} }
@@ -161,7 +161,7 @@ pipeline {
sh 'docker-compose down --remove-orphans --volumes -t 30 || true' sh 'docker-compose down --remove-orphans --volumes -t 30 || true'
} }
unstable { unstable {
dir(path: 'testing/results') { dir(path: 'test/results') {
archiveArtifacts(allowEmptyArchive: true, artifacts: '**/*', excludes: '**/*.xml') archiveArtifacts(allowEmptyArchive: true, artifacts: '**/*', excludes: '**/*.xml')
} }
} }
@@ -199,7 +199,7 @@ pipeline {
sh 'docker-compose down --remove-orphans --volumes -t 30 || true' sh 'docker-compose down --remove-orphans --volumes -t 30 || true'
} }
unstable { unstable {
dir(path: 'testing/results') { dir(path: 'test/results') {
archiveArtifacts(allowEmptyArchive: true, artifacts: '**/*', excludes: '**/*.xml') archiveArtifacts(allowEmptyArchive: true, artifacts: '**/*', excludes: '**/*.xml')
} }
} }

View File

@@ -258,6 +258,7 @@ const internalAccessList = {
}) })
.where('access_list.is_deleted', 0) .where('access_list.is_deleted', 0)
.andWhere('access_list.id', data.id) .andWhere('access_list.id', data.id)
.groupBy('access_list.id')
.allowGraph('[owner,items,clients,proxy_hosts.[certificate,access_list.[clients,items]]]') .allowGraph('[owner,items,clients,proxy_hosts.[certificate,access_list.[clients,items]]]')
.first(); .first();

View File

@@ -313,6 +313,9 @@ const internalCertificate = {
.where('is_deleted', 0) .where('is_deleted', 0)
.andWhere('id', data.id) .andWhere('id', data.id)
.allowGraph('[owner]') .allowGraph('[owner]')
.allowGraph('[proxy_hosts]')
.allowGraph('[redirection_hosts]')
.allowGraph('[dead_hosts]')
.first(); .first();
if (access_data.permission_visibility !== 'all') { if (access_data.permission_visibility !== 'all') {
@@ -464,6 +467,9 @@ const internalCertificate = {
.where('is_deleted', 0) .where('is_deleted', 0)
.groupBy('id') .groupBy('id')
.allowGraph('[owner]') .allowGraph('[owner]')
.allowGraph('[proxy_hosts]')
.allowGraph('[redirection_hosts]')
.allowGraph('[dead_hosts]')
.orderBy('nice_name', 'ASC'); .orderBy('nice_name', 'ASC');
if (access_data.permission_visibility !== 'all') { if (access_data.permission_visibility !== 'all') {

97
backend/internal/mfa.js Normal file
View File

@@ -0,0 +1,97 @@
const authModel = require('../models/auth');
const error = require('../lib/error');
const speakeasy = require('speakeasy');
module.exports = {
validateMfaTokenForUser: (userId, token) => {
return authModel
.query()
.where('user_id', userId)
.first()
.then((auth) => {
if (!auth || !auth.mfa_enabled) {
throw new error.AuthError('MFA is not enabled for this user.');
}
const verified = speakeasy.totp.verify({
secret: auth.mfa_secret,
encoding: 'base32',
token: token,
window: 2
});
if (!verified) {
throw new error.AuthError('Invalid MFA token.');
}
return true;
});
},
isMfaEnabledForUser: (userId) => {
return authModel
.query()
.where('user_id', userId)
.first()
.then((auth) => {
console.log(auth);
if (!auth) {
throw new error.AuthError('User not found.');
}
return auth.mfa_enabled === true;
});
},
createMfaSecretForUser: (userId) => {
const secret = speakeasy.generateSecret({ length: 20 });
console.log(secret);
return authModel
.query()
.where('user_id', userId)
.update({
mfa_secret: secret.base32
})
.then(() => secret);
},
enableMfaForUser: (userId, token) => {
return authModel
.query()
.where('user_id', userId)
.first()
.then((auth) => {
if (!auth || !auth.mfa_secret) {
throw new error.AuthError('MFA is not set up for this user.');
}
const verified = speakeasy.totp.verify({
secret: auth.mfa_secret,
encoding: 'base32',
token: token,
window: 2
});
if (!verified) {
throw new error.AuthError('Invalid MFA token.');
}
return authModel
.query()
.where('user_id', userId)
.update({ mfa_enabled: true })
.then(() => true);
});
},
disableMfaForUser: (data, userId) => {
return authModel
.query()
.where('user_id', userId)
.first()
.then((auth) => {
if (!auth) {
throw new error.AuthError('User not found.');
}
return auth.verifyPassword(data.secret)
.then((valid) => {
if (!valid) {
throw new error.AuthError('Invalid password.');
}
return authModel
.query()
.where('user_id', userId)
.update({ mfa_enabled: false, mfa_secret: null });
});
});
},
};

View File

@@ -4,10 +4,12 @@ const utils = require('../lib/utils');
const streamModel = require('../models/stream'); const streamModel = require('../models/stream');
const internalNginx = require('./nginx'); const internalNginx = require('./nginx');
const internalAuditLog = require('./audit-log'); const internalAuditLog = require('./audit-log');
const internalCertificate = require('./certificate');
const internalHost = require('./host');
const {castJsonIfNeed} = require('../lib/helpers'); const {castJsonIfNeed} = require('../lib/helpers');
function omissions () { function omissions () {
return ['is_deleted']; return ['is_deleted', 'owner.is_deleted', 'certificate.is_deleted'];
} }
const internalStream = { const internalStream = {
@@ -18,6 +20,12 @@ const internalStream = {
* @returns {Promise} * @returns {Promise}
*/ */
create: (access, data) => { create: (access, data) => {
const create_certificate = data.certificate_id === 'new';
if (create_certificate) {
delete data.certificate_id;
}
return access.can('streams:create', data) return access.can('streams:create', data)
.then((/*access_data*/) => { .then((/*access_data*/) => {
// TODO: At this point the existing ports should have been checked // TODO: At this point the existing ports should have been checked
@@ -27,16 +35,44 @@ const internalStream = {
data.meta = {}; data.meta = {};
} }
// streams aren't routed by domain name so don't store domain names in the DB
let data_no_domains = structuredClone(data);
delete data_no_domains.domain_names;
return streamModel return streamModel
.query() .query()
.insertAndFetch(data) .insertAndFetch(data_no_domains)
.then(utils.omitRow(omissions())); .then(utils.omitRow(omissions()));
}) })
.then((row) => {
if (create_certificate) {
return internalCertificate.createQuickCertificate(access, data)
.then((cert) => {
// update host with cert id
return internalStream.update(access, {
id: row.id,
certificate_id: cert.id
});
})
.then(() => {
return row;
});
} else {
return row;
}
})
.then((row) => {
// re-fetch with cert
return internalStream.get(access, {
id: row.id,
expand: ['certificate', 'owner']
});
})
.then((row) => { .then((row) => {
// Configure nginx // Configure nginx
return internalNginx.configure(streamModel, 'stream', row) return internalNginx.configure(streamModel, 'stream', row)
.then(() => { .then(() => {
return internalStream.get(access, {id: row.id, expand: ['owner']}); return row;
}); });
}) })
.then((row) => { .then((row) => {
@@ -60,6 +96,12 @@ const internalStream = {
* @return {Promise} * @return {Promise}
*/ */
update: (access, data) => { update: (access, data) => {
const create_certificate = data.certificate_id === 'new';
if (create_certificate) {
delete data.certificate_id;
}
return access.can('streams:update', data.id) return access.can('streams:update', data.id)
.then((/*access_data*/) => { .then((/*access_data*/) => {
// TODO: at this point the existing streams should have been checked // TODO: at this point the existing streams should have been checked
@@ -71,16 +113,32 @@ const internalStream = {
throw new error.InternalValidationError('Stream could not be updated, IDs do not match: ' + row.id + ' !== ' + data.id); throw new error.InternalValidationError('Stream could not be updated, IDs do not match: ' + row.id + ' !== ' + data.id);
} }
if (create_certificate) {
return internalCertificate.createQuickCertificate(access, {
domain_names: data.domain_names || row.domain_names,
meta: _.assign({}, row.meta, data.meta)
})
.then((cert) => {
// update host with cert id
data.certificate_id = cert.id;
})
.then(() => {
return row;
});
} else {
return row;
}
})
.then((row) => {
// Add domain_names to the data in case it isn't there, so that the audit log renders correctly. The order is important here.
data = _.assign({}, {
domain_names: row.domain_names
}, data);
return streamModel return streamModel
.query() .query()
.patchAndFetchById(row.id, data) .patchAndFetchById(row.id, data)
.then(utils.omitRow(omissions())) .then(utils.omitRow(omissions()))
.then((saved_row) => {
return internalNginx.configure(streamModel, 'stream', saved_row)
.then(() => {
return internalStream.get(access, {id: row.id, expand: ['owner']});
});
})
.then((saved_row) => { .then((saved_row) => {
// Add to audit log // Add to audit log
return internalAuditLog.add(access, { return internalAuditLog.add(access, {
@@ -93,6 +151,17 @@ const internalStream = {
return saved_row; return saved_row;
}); });
}); });
})
.then(() => {
return internalStream.get(access, {id: data.id, expand: ['owner', 'certificate']})
.then((row) => {
return internalNginx.configure(streamModel, 'stream', row)
.then((new_meta) => {
row.meta = new_meta;
row = internalHost.cleanRowCertificateMeta(row);
return _.omit(row, omissions());
});
});
}); });
}, },
@@ -115,7 +184,7 @@ const internalStream = {
.query() .query()
.where('is_deleted', 0) .where('is_deleted', 0)
.andWhere('id', data.id) .andWhere('id', data.id)
.allowGraph('[owner]') .allowGraph('[owner,certificate]')
.first(); .first();
if (access_data.permission_visibility !== 'all') { if (access_data.permission_visibility !== 'all') {
@@ -132,6 +201,7 @@ const internalStream = {
if (!row || !row.id) { if (!row || !row.id) {
throw new error.ItemNotFoundError(data.id); throw new error.ItemNotFoundError(data.id);
} }
row = internalHost.cleanRowCertificateMeta(row);
// Custom omissions // Custom omissions
if (typeof data.omit !== 'undefined' && data.omit !== null) { if (typeof data.omit !== 'undefined' && data.omit !== null) {
row = _.omit(row, data.omit); row = _.omit(row, data.omit);
@@ -197,14 +267,14 @@ const internalStream = {
.then(() => { .then(() => {
return internalStream.get(access, { return internalStream.get(access, {
id: data.id, id: data.id,
expand: ['owner'] expand: ['certificate', 'owner']
}); });
}) })
.then((row) => { .then((row) => {
if (!row || !row.id) { if (!row || !row.id) {
throw new error.ItemNotFoundError(data.id); throw new error.ItemNotFoundError(data.id);
} else if (row.enabled) { } else if (row.enabled) {
throw new error.ValidationError('Host is already enabled'); throw new error.ValidationError('Stream is already enabled');
} }
row.enabled = 1; row.enabled = 1;
@@ -250,7 +320,7 @@ const internalStream = {
if (!row || !row.id) { if (!row || !row.id) {
throw new error.ItemNotFoundError(data.id); throw new error.ItemNotFoundError(data.id);
} else if (!row.enabled) { } else if (!row.enabled) {
throw new error.ValidationError('Host is already disabled'); throw new error.ValidationError('Stream is already disabled');
} }
row.enabled = 0; row.enabled = 0;
@@ -298,7 +368,7 @@ const internalStream = {
.query() .query()
.where('is_deleted', 0) .where('is_deleted', 0)
.groupBy('id') .groupBy('id')
.allowGraph('[owner]') .allowGraph('[owner,certificate]')
.orderByRaw('CAST(incoming_port AS INTEGER) ASC'); .orderByRaw('CAST(incoming_port AS INTEGER) ASC');
if (access_data.permission_visibility !== 'all') { if (access_data.permission_visibility !== 'all') {
@@ -317,6 +387,13 @@ const internalStream = {
} }
return query.then(utils.omitRows(omissions())); return query.then(utils.omitRows(omissions()));
})
.then((rows) => {
if (typeof expand !== 'undefined' && expand !== null && expand.indexOf('certificate') !== -1) {
return internalHost.cleanAllRowsCertificateMeta(rows);
}
return rows;
}); });
}, },

View File

@@ -4,6 +4,7 @@ const userModel = require('../models/user');
const authModel = require('../models/auth'); const authModel = require('../models/auth');
const helpers = require('../lib/helpers'); const helpers = require('../lib/helpers');
const TokenModel = require('../models/token'); const TokenModel = require('../models/token');
const mfa = require('../internal/mfa'); // <-- added MFA import
const ERROR_MESSAGE_INVALID_AUTH = 'Invalid email or password'; const ERROR_MESSAGE_INVALID_AUTH = 'Invalid email or password';
@@ -21,6 +22,8 @@ module.exports = {
getTokenFromEmail: (data, issuer) => { getTokenFromEmail: (data, issuer) => {
let Token = new TokenModel(); let Token = new TokenModel();
console.log(data);
data.scope = data.scope || 'user'; data.scope = data.scope || 'user';
data.expiry = data.expiry || '1d'; data.expiry = data.expiry || '1d';
@@ -41,15 +44,23 @@ module.exports = {
.then((auth) => { .then((auth) => {
if (auth) { if (auth) {
return auth.verifyPassword(data.secret) return auth.verifyPassword(data.secret)
.then((valid) => { .then(async (valid) => {
if (valid) { if (valid) {
if (data.scope !== 'user' && _.indexOf(user.roles, data.scope) === -1) { if (data.scope !== 'user' && _.indexOf(user.roles, data.scope) === -1) {
// The scope requested doesn't exist as a role against the user,
// you shall not pass.
throw new error.AuthError('Invalid scope: ' + data.scope); throw new error.AuthError('Invalid scope: ' + data.scope);
} }
return await mfa.isMfaEnabledForUser(user.id)
.then((mfaEnabled) => {
if (mfaEnabled) {
if (!data.mfa_token) {
throw new error.AuthError('MFA token required');
}
console.log(data.mfa_token);
return mfa.validateMfaTokenForUser(user.id, data.mfa_token)
.then((mfaValid) => {
if (!mfaValid) {
throw new error.AuthError('Invalid MFA token');
}
// Create a moment of the expiry expression // Create a moment of the expiry expression
let expiry = helpers.parseDatePeriod(data.expiry); let expiry = helpers.parseDatePeriod(data.expiry);
if (expiry === null) { if (expiry === null) {
@@ -70,6 +81,30 @@ module.exports = {
expires: expiry.toISOString() expires: expiry.toISOString()
}; };
}); });
});
} else {
// Create a moment of the expiry expression
let expiry = helpers.parseDatePeriod(data.expiry);
if (expiry === null) {
throw new error.AuthError('Invalid expiry time: ' + data.expiry);
}
return Token.create({
iss: issuer || 'api',
attrs: {
id: user.id
},
scope: [data.scope],
expiresIn: data.expiry
})
.then((signed) => {
return {
token: signed.token,
expires: expiry.toISOString()
};
});
}
});
} else { } else {
throw new error.AuthError(ERROR_MESSAGE_INVALID_AUTH); throw new error.AuthError(ERROR_MESSAGE_INVALID_AUTH);
} }

View File

@@ -507,7 +507,8 @@ const internalUser = {
.then((user) => { .then((user) => {
return internalToken.getTokenFromUser(user); return internalToken.getTokenFromUser(user);
}); });
} },
}; };
module.exports = internalUser; module.exports = internalUser;

View File

@@ -0,0 +1,38 @@
const migrate_name = 'stream_ssl';
const logger = require('../logger').migrate;
/**
* Migrate
*
* @see http://knexjs.org/#Schema
*
* @param {Object} knex
* @returns {Promise}
*/
exports.up = function (knex) {
logger.info('[' + migrate_name + '] Migrating Up...');
return knex.schema.table('stream', (table) => {
table.integer('certificate_id').notNull().unsigned().defaultTo(0);
})
.then(function () {
logger.info('[' + migrate_name + '] stream Table altered');
});
};
/**
* Undo Migrate
*
* @param {Object} knex
* @returns {Promise}
*/
exports.down = function (knex) {
logger.info('[' + migrate_name + '] Migrating Down...');
return knex.schema.table('stream', (table) => {
table.dropColumn('certificate_id');
})
.then(function () {
logger.info('[' + migrate_name + '] stream Table altered');
});
};

View File

@@ -0,0 +1,45 @@
const migrate_name = 'identifier_for_migrate';
const logger = require('../logger').migrate;
/**
* Migrate
*
* @see http://knexjs.org/#Schema
*
* @param {Object} knex
* @param {Promise} Promise
* @returns {Promise}
*/
exports.up = function (knex/*, Promise*/) {
logger.info('[' + migrate_name + '] Migrating Up...');
return knex.schema.alterTable('auth', (table) => {
table.string('mfa_secret');
table.boolean('mfa_enabled').defaultTo(false);
})
.then(() => {
logger.info('[' + migrate_name + '] User Table altered');
logger.info('[' + migrate_name + '] Migrating Up Complete');
});
};
/**
* Undo Migrate
*
* @param {Object} knex
* @param {Promise} Promise
* @returns {Promise}
*/
exports.down = function (knex/*, Promise*/) {
logger.info('[' + migrate_name + '] Migrating Down...');
return knex.schema.alterTable('auth', (table) => {
table.dropColumn('mfa_key');
table.dropColumn('mfa_enabled');
})
.then(() => {
logger.info('[' + migrate_name + '] User Table altered');
logger.info('[' + migrate_name + '] Migrating Down Complete');
});
};

View File

@@ -4,7 +4,6 @@
const db = require('../db'); const db = require('../db');
const helpers = require('../lib/helpers'); const helpers = require('../lib/helpers');
const Model = require('objection').Model; const Model = require('objection').Model;
const User = require('./user');
const now = require('./now_helper'); const now = require('./now_helper');
Model.knex(db); Model.knex(db);
@@ -68,6 +67,11 @@ class Certificate extends Model {
} }
static get relationMappings () { static get relationMappings () {
const ProxyHost = require('./proxy_host');
const DeadHost = require('./dead_host');
const User = require('./user');
const RedirectionHost = require('./redirection_host');
return { return {
owner: { owner: {
relation: Model.HasOneRelation, relation: Model.HasOneRelation,
@@ -79,6 +83,39 @@ class Certificate extends Model {
modify: function (qb) { modify: function (qb) {
qb.where('user.is_deleted', 0); qb.where('user.is_deleted', 0);
} }
},
proxy_hosts: {
relation: Model.HasManyRelation,
modelClass: ProxyHost,
join: {
from: 'certificate.id',
to: 'proxy_host.certificate_id'
},
modify: function (qb) {
qb.where('proxy_host.is_deleted', 0);
}
},
dead_hosts: {
relation: Model.HasManyRelation,
modelClass: DeadHost,
join: {
from: 'certificate.id',
to: 'dead_host.certificate_id'
},
modify: function (qb) {
qb.where('dead_host.is_deleted', 0);
}
},
redirection_hosts: {
relation: Model.HasManyRelation,
modelClass: RedirectionHost,
join: {
from: 'certificate.id',
to: 'redirection_host.certificate_id'
},
modify: function (qb) {
qb.where('redirection_host.is_deleted', 0);
}
} }
}; };
} }

View File

@@ -1,15 +1,14 @@
// Objection Docs: const Model = require('objection').Model;
// http://vincit.github.io/objection.js/
const db = require('../db'); const db = require('../db');
const helpers = require('../lib/helpers'); const helpers = require('../lib/helpers');
const Model = require('objection').Model;
const User = require('./user'); const User = require('./user');
const Certificate = require('./certificate');
const now = require('./now_helper'); const now = require('./now_helper');
Model.knex(db); Model.knex(db);
const boolFields = [ const boolFields = [
'enabled',
'is_deleted', 'is_deleted',
'tcp_forwarding', 'tcp_forwarding',
'udp_forwarding', 'udp_forwarding',
@@ -64,6 +63,17 @@ class Stream extends Model {
modify: function (qb) { modify: function (qb) {
qb.where('user.is_deleted', 0); qb.where('user.is_deleted', 0);
} }
},
certificate: {
relation: Model.HasOneRelation,
modelClass: Certificate,
join: {
from: 'stream.certificate_id',
to: 'certificate.id'
},
modify: function (qb) {
qb.where('certificate.is_deleted', 0);
}
} }
}; };
} }

View File

@@ -23,8 +23,10 @@
"node-rsa": "^1.0.8", "node-rsa": "^1.0.8",
"objection": "3.0.1", "objection": "3.0.1",
"path": "^0.12.7", "path": "^0.12.7",
"qrcode": "^1.5.4",
"pg": "^8.13.1", "pg": "^8.13.1",
"signale": "1.4.0", "signale": "1.4.0",
"speakeasy": "^2.0.0",
"sqlite3": "5.1.6", "sqlite3": "5.1.6",
"temp-write": "^4.0.0" "temp-write": "^4.0.0"
}, },

View File

@@ -27,6 +27,7 @@ router.get('/', (req, res/*, next*/) => {
router.use('/schema', require('./schema')); router.use('/schema', require('./schema'));
router.use('/tokens', require('./tokens')); router.use('/tokens', require('./tokens'));
router.use('/mfa', require('./mfa'));
router.use('/users', require('./users')); router.use('/users', require('./users'));
router.use('/audit-log', require('./audit-log')); router.use('/audit-log', require('./audit-log'));
router.use('/reports', require('./reports')); router.use('/reports', require('./reports'));

81
backend/routes/mfa.js Normal file
View File

@@ -0,0 +1,81 @@
const express = require('express');
const jwtdecode = require('../lib/express/jwt-decode');
const apiValidator = require('../lib/validator/api');
const schema = require('../schema');
const internalMfa = require('../internal/mfa');
const qrcode = require('qrcode');
const speakeasy = require('speakeasy');
const userModel = require('../models/user');
let router = express.Router({
caseSensitive: true,
strict: true,
mergeParams: true
});
router
.route('/create')
.post(jwtdecode(), (req, res, next) => {
if (!res.locals.access) {
return next(new Error('Invalid token'));
}
const userId = res.locals.access.token.getUserId();
internalMfa.createMfaSecretForUser(userId)
.then((secret) => {
return userModel.query()
.where('id', '=', userId)
.first()
.then((user) => {
if (!user) {
return next(new Error('User not found'));
}
return { secret, user };
});
})
.then(({ secret, user }) => {
const otpAuthUrl = speakeasy.otpauthURL({
secret: secret.ascii,
label: user.email,
issuer: 'Nginx Proxy Manager'
});
qrcode.toDataURL(otpAuthUrl, (err, dataUrl) => {
if (err) {
console.error('Error generating QR code:', err);
return next(err);
}
res.status(200).send({ qrCode: dataUrl });
});
})
.catch(next);
});
router
.route('/enable')
.post(jwtdecode(), (req, res, next) => {
apiValidator(schema.getValidationSchema('/mfa/enable', 'post'), req.body).then((params) => {
internalMfa.enableMfaForUser(res.locals.access.token.getUserId(), params.token)
.then(() => res.status(200).send({ success: true }))
.catch(next);
}
).catch(next);
});
router
.route('/check')
.get(jwtdecode(), (req, res, next) => {
internalMfa.isMfaEnabledForUser(res.locals.access.token.getUserId())
.then((active) => res.status(200).send({ active }))
.catch(next);
});
router
.route('/delete')
.delete(jwtdecode(), (req, res, next) => {
apiValidator(schema.getValidationSchema('/mfa/delete', 'delete'), req.body).then((params) => {
internalMfa.disableMfaForUser(params, res.locals.access.token.getUserId())
.then(() => res.status(200).send({ success: true }))
.catch(next);
}).catch(next);
});
module.exports = router;

View File

@@ -19,9 +19,7 @@
"incoming_port": { "incoming_port": {
"type": "integer", "type": "integer",
"minimum": 1, "minimum": 1,
"maximum": 65535, "maximum": 65535
"if": {"properties": {"tcp_forwarding": {"const": true}}},
"then": {"not": {"oneOf": [{"const": 80}, {"const": 443}]}}
}, },
"forwarding_host": { "forwarding_host": {
"anyOf": [ "anyOf": [
@@ -55,8 +53,24 @@
"enabled": { "enabled": {
"$ref": "../common.json#/properties/enabled" "$ref": "../common.json#/properties/enabled"
}, },
"certificate_id": {
"$ref": "../common.json#/properties/certificate_id"
},
"meta": { "meta": {
"type": "object" "type": "object"
},
"owner": {
"$ref": "./user-object.json"
},
"certificate": {
"oneOf": [
{
"type": "null"
},
{
"$ref": "./certificate-object.json"
}
]
} }
} }
} }

View File

@@ -5,10 +5,9 @@
"additionalProperties": false, "additionalProperties": false,
"properties": { "properties": {
"expires": { "expires": {
"description": "Token Expiry Unix Time", "description": "Token Expiry ISO Time String",
"example": 1566540249, "example": "2025-02-04T20:40:46.340Z",
"minimum": 1, "type": "string"
"type": "number"
}, },
"token": { "token": {
"description": "JWT Token", "description": "JWT Token",

View File

@@ -0,0 +1,44 @@
{
"operationId": "disableMfa",
"summary": "Disable multi-factor authentication for a user",
"tags": [
"MFA"
],
"requestBody": {
"description": "Payload to disable MFA",
"required": true,
"content": {
"application/json": {
"schema": {
"additionalProperties": false,
"properties": {
"secret": {
"type": "string",
"minLength": 1
}
},
"required": [
"secret"
]
}
}
}
},
"responses": {
"200": {
"description": "MFA disabled successfully",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean"
}
}
}
}
}
}
}
}

View File

@@ -0,0 +1,44 @@
{
"operationId": "enableMfa",
"summary": "Enable multi-factor authentication for a user",
"tags": [
"MFA"
],
"requestBody": {
"description": "MFA Token Payload",
"required": true,
"content": {
"application/json": {
"schema": {
"additionalProperties": false,
"properties": {
"token": {
"type": "string",
"minLength": 1
}
},
"required": [
"token"
]
}
}
}
},
"responses": {
"200": {
"description": "MFA enabled successfully",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean"
}
}
}
}
}
}
}
}

View File

@@ -14,7 +14,7 @@
"description": "Expansions", "description": "Expansions",
"schema": { "schema": {
"type": "string", "type": "string",
"enum": ["access_list", "owner", "certificate"] "enum": ["owner", "certificate"]
} }
} }
], ],
@@ -40,7 +40,8 @@
"nginx_online": true, "nginx_online": true,
"nginx_err": null "nginx_err": null
}, },
"enabled": true "enabled": true,
"certificate_id": 0
} }
] ]
} }

View File

@@ -32,6 +32,9 @@
"udp_forwarding": { "udp_forwarding": {
"$ref": "../../../components/stream-object.json#/properties/udp_forwarding" "$ref": "../../../components/stream-object.json#/properties/udp_forwarding"
}, },
"certificate_id": {
"$ref": "../../../components/stream-object.json#/properties/certificate_id"
},
"meta": { "meta": {
"$ref": "../../../components/stream-object.json#/properties/meta" "$ref": "../../../components/stream-object.json#/properties/meta"
} }
@@ -73,7 +76,8 @@
"nickname": "Admin", "nickname": "Admin",
"avatar": "", "avatar": "",
"roles": ["admin"] "roles": ["admin"]
} },
"certificate_id": 0
} }
} }
}, },

View File

@@ -40,7 +40,8 @@
"nginx_online": true, "nginx_online": true,
"nginx_err": null "nginx_err": null
}, },
"enabled": true "enabled": true,
"certificate_id": 0
} }
} }
}, },

View File

@@ -29,56 +29,26 @@
"additionalProperties": false, "additionalProperties": false,
"minProperties": 1, "minProperties": 1,
"properties": { "properties": {
"domain_names": { "incoming_port": {
"$ref": "../../../../components/proxy-host-object.json#/properties/domain_names" "$ref": "../../../../components/stream-object.json#/properties/incoming_port"
}, },
"forward_scheme": { "forwarding_host": {
"$ref": "../../../../components/proxy-host-object.json#/properties/forward_scheme" "$ref": "../../../../components/stream-object.json#/properties/forwarding_host"
}, },
"forward_host": { "forwarding_port": {
"$ref": "../../../../components/proxy-host-object.json#/properties/forward_host" "$ref": "../../../../components/stream-object.json#/properties/forwarding_port"
}, },
"forward_port": { "tcp_forwarding": {
"$ref": "../../../../components/proxy-host-object.json#/properties/forward_port" "$ref": "../../../../components/stream-object.json#/properties/tcp_forwarding"
},
"udp_forwarding": {
"$ref": "../../../../components/stream-object.json#/properties/udp_forwarding"
}, },
"certificate_id": { "certificate_id": {
"$ref": "../../../../components/proxy-host-object.json#/properties/certificate_id" "$ref": "../../../../components/stream-object.json#/properties/certificate_id"
},
"ssl_forced": {
"$ref": "../../../../components/proxy-host-object.json#/properties/ssl_forced"
},
"hsts_enabled": {
"$ref": "../../../../components/proxy-host-object.json#/properties/hsts_enabled"
},
"hsts_subdomains": {
"$ref": "../../../../components/proxy-host-object.json#/properties/hsts_subdomains"
},
"http2_support": {
"$ref": "../../../../components/proxy-host-object.json#/properties/http2_support"
},
"block_exploits": {
"$ref": "../../../../components/proxy-host-object.json#/properties/block_exploits"
},
"caching_enabled": {
"$ref": "../../../../components/proxy-host-object.json#/properties/caching_enabled"
},
"allow_websocket_upgrade": {
"$ref": "../../../../components/proxy-host-object.json#/properties/allow_websocket_upgrade"
},
"access_list_id": {
"$ref": "../../../../components/proxy-host-object.json#/properties/access_list_id"
},
"advanced_config": {
"$ref": "../../../../components/proxy-host-object.json#/properties/advanced_config"
},
"enabled": {
"$ref": "../../../../components/proxy-host-object.json#/properties/enabled"
}, },
"meta": { "meta": {
"$ref": "../../../../components/proxy-host-object.json#/properties/meta" "$ref": "../../../../components/stream-object.json#/properties/meta"
},
"locations": {
"$ref": "../../../../components/proxy-host-object.json#/properties/locations"
} }
} }
} }
@@ -94,42 +64,32 @@
"default": { "default": {
"value": { "value": {
"id": 1, "id": 1,
"created_on": "2024-10-08T23:23:03.000Z", "created_on": "2024-10-09T02:33:45.000Z",
"modified_on": "2024-10-08T23:26:37.000Z", "modified_on": "2024-10-09T02:33:45.000Z",
"owner_user_id": 1, "owner_user_id": 1,
"domain_names": ["test.example.com"], "incoming_port": 9090,
"forward_host": "192.168.0.10", "forwarding_host": "router.internal",
"forward_port": 8989, "forwarding_port": 80,
"access_list_id": 0, "tcp_forwarding": true,
"certificate_id": 0, "udp_forwarding": false,
"ssl_forced": false,
"caching_enabled": false,
"block_exploits": false,
"advanced_config": "",
"meta": { "meta": {
"nginx_online": true, "nginx_online": true,
"nginx_err": null "nginx_err": null
}, },
"allow_websocket_upgrade": false,
"http2_support": false,
"forward_scheme": "http",
"enabled": true, "enabled": true,
"hsts_enabled": false,
"hsts_subdomains": false,
"owner": { "owner": {
"id": 1, "id": 1,
"created_on": "2024-10-07T22:43:55.000Z", "created_on": "2024-10-09T02:33:16.000Z",
"modified_on": "2024-10-08T12:52:54.000Z", "modified_on": "2024-10-09T02:33:16.000Z",
"is_deleted": false, "is_deleted": false,
"is_disabled": false, "is_disabled": false,
"email": "admin@example.com", "email": "admin@example.com",
"name": "Administrator", "name": "Administrator",
"nickname": "some guy", "nickname": "Admin",
"avatar": "//www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?default=mm", "avatar": "",
"roles": ["admin"] "roles": ["admin"]
}, },
"certificate": null, "certificate_id": 0
"access_list": null
} }
} }
}, },

View File

@@ -15,7 +15,7 @@
"examples": { "examples": {
"default": { "default": {
"value": { "value": {
"expires": 1566540510, "expires": "2025-02-04T20:40:46.340Z",
"token": "eyJhbGciOiJSUzUxMiIsInR5cCI6IkpXVCJ9.ey...xaHKYr3Kk6MvkUjcC4" "token": "eyJhbGciOiJSUzUxMiIsInR5cCI6IkpXVCJ9.ey...xaHKYr3Kk6MvkUjcC4"
} }
} }

View File

@@ -22,6 +22,10 @@
"secret": { "secret": {
"minLength": 1, "minLength": 1,
"type": "string" "type": "string"
},
"mfa_token": {
"minLength": 1,
"type": "string"
} }
}, },
"required": ["identity", "secret"], "required": ["identity", "secret"],
@@ -38,7 +42,7 @@
"default": { "default": {
"value": { "value": {
"result": { "result": {
"expires": 1566540510, "expires": "2025-02-04T20:40:46.340Z",
"token": "eyJhbGciOiJSUzUxMiIsInR5cCI6IkpXVCJ9.ey...xaHKYr3Kk6MvkUjcC4" "token": "eyJhbGciOiJSUzUxMiIsInR5cCI6IkpXVCJ9.ey...xaHKYr3Kk6MvkUjcC4"
} }
} }

View File

@@ -14,7 +14,7 @@
"application/json": { "application/json": {
"schema": { "schema": {
"type": "object", "type": "object",
"additionalProperties": false, "additionalProperties": true,
"required": ["name", "nickname", "email"], "required": ["name", "nickname", "email"],
"properties": { "properties": {
"name": { "name": {

View File

@@ -15,6 +15,16 @@
"$ref": "./paths/get.json" "$ref": "./paths/get.json"
} }
}, },
"/mfa/enable": {
"post": {
"$ref": "./paths/mfa/enable/post.json"
}
},
"/mfa/delete": {
"delete": {
"$ref": "./paths/mfa/delete/delete.json"
}
},
"/audit-log": { "/audit-log": {
"get": { "get": {
"$ref": "./paths/audit-log/get.json" "$ref": "./paths/audit-log/get.json"

View File

@@ -2,6 +2,7 @@
{% if certificate.provider == "letsencrypt" %} {% if certificate.provider == "letsencrypt" %}
# Let's Encrypt SSL # Let's Encrypt SSL
include conf.d/include/letsencrypt-acme-challenge.conf; include conf.d/include/letsencrypt-acme-challenge.conf;
include conf.d/include/ssl-cache.conf;
include conf.d/include/ssl-ciphers.conf; include conf.d/include/ssl-ciphers.conf;
ssl_certificate /etc/letsencrypt/live/npm-{{ certificate_id }}/fullchain.pem; ssl_certificate /etc/letsencrypt/live/npm-{{ certificate_id }}/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/npm-{{ certificate_id }}/privkey.pem; ssl_certificate_key /etc/letsencrypt/live/npm-{{ certificate_id }}/privkey.pem;

View File

@@ -0,0 +1,13 @@
{% if certificate and certificate_id > 0 %}
{% if certificate.provider == "letsencrypt" %}
# Let's Encrypt SSL
include conf.d/include/ssl-cache-stream.conf;
include conf.d/include/ssl-ciphers.conf;
ssl_certificate /etc/letsencrypt/live/npm-{{ certificate_id }}/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/npm-{{ certificate_id }}/privkey.pem;
{%- else %}
# Custom SSL
ssl_certificate /data/custom_ssl/npm-{{ certificate_id }}/fullchain.pem;
ssl_certificate_key /data/custom_ssl/npm-{{ certificate_id }}/privkey.pem;
{%- endif -%}
{%- endif -%}

View File

@@ -5,12 +5,10 @@
{% if enabled %} {% if enabled %}
{% if tcp_forwarding == 1 or tcp_forwarding == true -%} {% if tcp_forwarding == 1 or tcp_forwarding == true -%}
server { server {
listen {{ incoming_port }}; listen {{ incoming_port }} {%- if certificate %} ssl {%- endif %};
{% if ipv6 -%} {% unless ipv6 -%} # {%- endunless -%} listen [::]:{{ incoming_port }} {%- if certificate %} ssl {%- endif %};
listen [::]:{{ incoming_port }};
{% else -%} {%- include "_certificates_stream.conf" %}
#listen [::]:{{ incoming_port }};
{% endif %}
proxy_pass {{ forwarding_host }}:{{ forwarding_port }}; proxy_pass {{ forwarding_host }}:{{ forwarding_port }};
@@ -19,14 +17,12 @@ server {
include /data/nginx/custom/server_stream_tcp[.]conf; include /data/nginx/custom/server_stream_tcp[.]conf;
} }
{% endif %} {% endif %}
{% if udp_forwarding == 1 or udp_forwarding == true %}
{% if udp_forwarding == 1 or udp_forwarding == true -%}
server { server {
listen {{ incoming_port }} udp; listen {{ incoming_port }} udp;
{% if ipv6 -%} {% unless ipv6 -%} # {%- endunless -%} listen [::]:{{ incoming_port }} udp;
listen [::]:{{ incoming_port }} udp;
{% else -%}
#listen [::]:{{ incoming_port }} udp;
{% endif %}
proxy_pass {{ forwarding_host }}:{{ forwarding_port }}; proxy_pass {{ forwarding_host }}:{{ forwarding_port }};
# Custom # Custom

View File

@@ -2735,67 +2735,11 @@ path@^0.12.7:
process "^0.11.1" process "^0.11.1"
util "^0.10.3" util "^0.10.3"
pg-cloudflare@^1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/pg-cloudflare/-/pg-cloudflare-1.1.1.tgz#e6d5833015b170e23ae819e8c5d7eaedb472ca98"
integrity sha512-xWPagP/4B6BgFO+EKz3JONXv3YDgvkbVrGw2mTo3D6tVDQRh1e7cqVGvyR3BE+eQgAvx1XhW/iEASj4/jCWl3Q==
pg-connection-string@2.5.0: pg-connection-string@2.5.0:
version "2.5.0" version "2.5.0"
resolved "https://registry.yarnpkg.com/pg-connection-string/-/pg-connection-string-2.5.0.tgz#538cadd0f7e603fc09a12590f3b8a452c2c0cf34" resolved "https://registry.yarnpkg.com/pg-connection-string/-/pg-connection-string-2.5.0.tgz#538cadd0f7e603fc09a12590f3b8a452c2c0cf34"
integrity sha512-r5o/V/ORTA6TmUnyWZR9nCj1klXCO2CEKNRlVuJptZe85QuhFayC7WeMic7ndayT5IRIR0S0xFxFi2ousartlQ== integrity sha512-r5o/V/ORTA6TmUnyWZR9nCj1klXCO2CEKNRlVuJptZe85QuhFayC7WeMic7ndayT5IRIR0S0xFxFi2ousartlQ==
pg-connection-string@^2.7.0:
version "2.7.0"
resolved "https://registry.yarnpkg.com/pg-connection-string/-/pg-connection-string-2.7.0.tgz#f1d3489e427c62ece022dba98d5262efcb168b37"
integrity sha512-PI2W9mv53rXJQEOb8xNR8lH7Hr+EKa6oJa38zsK0S/ky2er16ios1wLKhZyxzD7jUReiWokc9WK5nxSnC7W1TA==
pg-int8@1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/pg-int8/-/pg-int8-1.0.1.tgz#943bd463bf5b71b4170115f80f8efc9a0c0eb78c"
integrity sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==
pg-pool@^3.7.0:
version "3.7.0"
resolved "https://registry.yarnpkg.com/pg-pool/-/pg-pool-3.7.0.tgz#d4d3c7ad640f8c6a2245adc369bafde4ebb8cbec"
integrity sha512-ZOBQForurqh4zZWjrgSwwAtzJ7QiRX0ovFkZr2klsen3Nm0aoh33Ls0fzfv3imeH/nw/O27cjdz5kzYJfeGp/g==
pg-protocol@^1.7.0:
version "1.7.0"
resolved "https://registry.yarnpkg.com/pg-protocol/-/pg-protocol-1.7.0.tgz#ec037c87c20515372692edac8b63cf4405448a93"
integrity sha512-hTK/mE36i8fDDhgDFjy6xNOG+LCorxLG3WO17tku+ij6sVHXh1jQUJ8hYAnRhNla4QVD2H8er/FOjc/+EgC6yQ==
pg-types@^2.1.0:
version "2.2.0"
resolved "https://registry.yarnpkg.com/pg-types/-/pg-types-2.2.0.tgz#2d0250d636454f7cfa3b6ae0382fdfa8063254a3"
integrity sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==
dependencies:
pg-int8 "1.0.1"
postgres-array "~2.0.0"
postgres-bytea "~1.0.0"
postgres-date "~1.0.4"
postgres-interval "^1.1.0"
pg@^8.13.1:
version "8.13.1"
resolved "https://registry.yarnpkg.com/pg/-/pg-8.13.1.tgz#6498d8b0a87ff76c2df7a32160309d3168c0c080"
integrity sha512-OUir1A0rPNZlX//c7ksiu7crsGZTKSOXJPgtNiHGIlC9H0lO+NC6ZDYksSgBYY/thSWhnSRBv8w1lieNNGATNQ==
dependencies:
pg-connection-string "^2.7.0"
pg-pool "^3.7.0"
pg-protocol "^1.7.0"
pg-types "^2.1.0"
pgpass "1.x"
optionalDependencies:
pg-cloudflare "^1.1.1"
pgpass@1.x:
version "1.0.5"
resolved "https://registry.yarnpkg.com/pgpass/-/pgpass-1.0.5.tgz#9b873e4a564bb10fa7a7dbd55312728d422a223d"
integrity sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==
dependencies:
split2 "^4.1.0"
picomatch@^2.0.4, picomatch@^2.2.1: picomatch@^2.0.4, picomatch@^2.2.1:
version "2.2.2" version "2.2.2"
resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.2.2.tgz#21f333e9b6b8eaff02468f5146ea406d345f4dad" resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.2.2.tgz#21f333e9b6b8eaff02468f5146ea406d345f4dad"
@@ -2814,28 +2758,6 @@ pkg-conf@^2.1.0:
find-up "^2.0.0" find-up "^2.0.0"
load-json-file "^4.0.0" load-json-file "^4.0.0"
postgres-array@~2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/postgres-array/-/postgres-array-2.0.0.tgz#48f8fce054fbc69671999329b8834b772652d82e"
integrity sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==
postgres-bytea@~1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/postgres-bytea/-/postgres-bytea-1.0.0.tgz#027b533c0aa890e26d172d47cf9ccecc521acd35"
integrity sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==
postgres-date@~1.0.4:
version "1.0.7"
resolved "https://registry.yarnpkg.com/postgres-date/-/postgres-date-1.0.7.tgz#51bc086006005e5061c591cee727f2531bf641a8"
integrity sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==
postgres-interval@^1.1.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/postgres-interval/-/postgres-interval-1.2.0.tgz#b460c82cb1587507788819a06aa0fffdb3544695"
integrity sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==
dependencies:
xtend "^4.0.0"
prelude-ls@^1.2.1: prelude-ls@^1.2.1:
version "1.2.1" version "1.2.1"
resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396" resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396"
@@ -3272,11 +3194,6 @@ socks@^2.6.2:
ip "^2.0.0" ip "^2.0.0"
smart-buffer "^4.2.0" smart-buffer "^4.2.0"
split2@^4.1.0:
version "4.2.0"
resolved "https://registry.yarnpkg.com/split2/-/split2-4.2.0.tgz#c9c5920904d148bab0b9f67145f245a86aadbfa4"
integrity sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==
sprintf-js@~1.0.2: sprintf-js@~1.0.2:
version "1.0.3" version "1.0.3"
resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c" resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c"
@@ -3748,11 +3665,6 @@ xdg-basedir@^4.0.0:
resolved "https://registry.yarnpkg.com/xdg-basedir/-/xdg-basedir-4.0.0.tgz#4bc8d9984403696225ef83a1573cbbcb4e79db13" resolved "https://registry.yarnpkg.com/xdg-basedir/-/xdg-basedir-4.0.0.tgz#4bc8d9984403696225ef83a1573cbbcb4e79db13"
integrity sha512-PSNhEJDejZYV7h50BohL09Er9VaIefr2LMAf3OEmpCkjOi34eYyQYAXUTjEQtZJTKcF0E2UKTh+osDLsgNim9Q== integrity sha512-PSNhEJDejZYV7h50BohL09Er9VaIefr2LMAf3OEmpCkjOi34eYyQYAXUTjEQtZJTKcF0E2UKTh+osDLsgNim9Q==
xtend@^4.0.0:
version "4.0.2"
resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54"
integrity sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==
y18n@^4.0.0: y18n@^4.0.0:
version "4.0.1" version "4.0.1"
resolved "https://registry.yarnpkg.com/y18n/-/y18n-4.0.1.tgz#8db2b83c31c5d75099bb890b23f3094891e247d4" resolved "https://registry.yarnpkg.com/y18n/-/y18n-4.0.1.tgz#8db2b83c31c5d75099bb890b23f3094891e247d4"

View File

@@ -22,6 +22,10 @@ services:
test: ["CMD", "/usr/bin/check-health"] test: ["CMD", "/usr/bin/check-health"]
interval: 10s interval: 10s
timeout: 3s timeout: 3s
expose:
- '80-81/tcp'
- '443/tcp'
- '1500-1503/tcp'
networks: networks:
fulltest: fulltest:
aliases: aliases:
@@ -40,7 +44,7 @@ services:
- ca.internal - ca.internal
pdns: pdns:
image: pschiffe/pdns-mysql image: pschiffe/pdns-mysql:4.8
volumes: volumes:
- '/etc/localtime:/etc/localtime:ro' - '/etc/localtime:/etc/localtime:ro'
environment: environment:
@@ -97,7 +101,7 @@ services:
HTTP_PROXY: 'squid:3128' HTTP_PROXY: 'squid:3128'
HTTPS_PROXY: 'squid:3128' HTTPS_PROXY: 'squid:3128'
volumes: volumes:
- 'cypress_logs:/results' - 'cypress_logs:/test/results'
- './dev/resolv.conf:/etc/resolv.conf:ro' - './dev/resolv.conf:/etc/resolv.conf:ro'
- '/etc/localtime:/etc/localtime:ro' - '/etc/localtime:/etc/localtime:ro'
command: cypress run --browser chrome --config-file=cypress/config/ci.js command: cypress run --browser chrome --config-file=cypress/config/ci.js

View File

@@ -132,7 +132,7 @@ services:
- 8128:3128 - 8128:3128
pdns: pdns:
image: pschiffe/pdns-mysql image: pschiffe/pdns-mysql:4.8
container_name: npm2dev.pdns container_name: npm2dev.pdns
volumes: volumes:
- '/etc/localtime:/etc/localtime:ro' - '/etc/localtime:/etc/localtime:ro'

View File

@@ -0,0 +1,2 @@
ssl_session_timeout 5m;
ssl_session_cache shared:SSL_stream:50m;

View File

@@ -0,0 +1,2 @@
ssl_session_timeout 5m;
ssl_session_cache shared:SSL:50m;

View File

@@ -1,6 +1,3 @@
ssl_session_timeout 5m;
ssl_session_cache shared:SSL:50m;
# intermediate configuration. tweak to your needs. # intermediate configuration. tweak to your needs.
ssl_protocols TLSv1.2 TLSv1.3; ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers 'ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384'; ssl_ciphers 'ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384';

View File

@@ -8,7 +8,7 @@ BLUE='\E[1;34m'
GREEN='\E[1;32m' GREEN='\E[1;32m'
RESET='\E[0m' RESET='\E[0m'
S6_OVERLAY_VERSION=3.1.5.0 S6_OVERLAY_VERSION=3.2.0.2
TARGETPLATFORM=${1:-linux/amd64} TARGETPLATFORM=${1:-linux/amd64}
# Determine the correct binary file for the architecture given # Determine the correct binary file for the architecture given

View File

@@ -1065,9 +1065,9 @@ vfile@^6.0.0:
vfile-message "^4.0.0" vfile-message "^4.0.0"
vite@^5.4.8: vite@^5.4.8:
version "5.4.8" version "5.4.14"
resolved "https://registry.yarnpkg.com/vite/-/vite-5.4.8.tgz#af548ce1c211b2785478d3ba3e8da51e39a287e8" resolved "https://registry.yarnpkg.com/vite/-/vite-5.4.14.tgz#ff8255edb02134df180dcfca1916c37a6abe8408"
integrity sha512-FqrItQ4DT1NC4zCUqMB4c4AZORMKIa0m8/URVCZ77OZ/QSNeJ54bU1vrFADbDsuwfIPcgknRkmqakQcgnL4GiQ== integrity sha512-EK5cY7Q1D8JNhSaPKVK4pwBFvaTmZxEnoKXLG/U9gmdDcihQGNzFlgIvaxezFR4glP1LsuiedwMBqCXH3wZccA==
dependencies: dependencies:
esbuild "^0.21.3" esbuild "^0.21.3"
postcss "^8.4.43" postcss "^8.4.43"

View File

@@ -202,8 +202,50 @@ module.exports = {
return fetch('get', ''); return fetch('get', '');
}, },
Mfa: {
create: function () {
return fetch('post', 'mfa/create');
},
enable: function (token) {
return fetch('post', 'mfa/enable', {token: token});
},
check: function () {
return fetch('get', 'mfa/check');
},
delete: function (secret) {
return fetch('delete', 'mfa/delete', {secret: secret});
}
},
Tokens: { Tokens: {
/**
*
* @param {String} identity
* @param {String} secret
* @param {String} token
* @param {Boolean} wipe
* @returns {Promise}
*/
loginWithMFA: function (identity, secret, mfaToken, wipe) {
return fetch('post', 'tokens', {identity: identity, secret: secret, mfa_token: mfaToken})
.then(response => {
if (response.token) {
if (wipe) {
Tokens.clearTokens();
}
// Set storage token
Tokens.addToken(response.token);
return response.token;
} else {
Tokens.clearTokens();
throw(new Error('No token returned'));
}
});
},
/** /**
* @param {String} identity * @param {String} identity
* @param {String} secret * @param {String} secret

View File

@@ -50,7 +50,6 @@ module.exports = Mn.View.extend({
onRender: function () { onRender: function () {
let view = this; let view = this;
if (typeof view.stats.hosts === 'undefined') {
Api.Reports.getHostStats() Api.Reports.getHostStats()
.then(response => { .then(response => {
if (!view.isDestroyed()) { if (!view.isDestroyed()) {
@@ -61,7 +60,6 @@ module.exports = Mn.View.extend({
.catch(err => { .catch(err => {
console.log(err); console.log(err);
}); });
}
}, },
/** /**

View File

@@ -33,6 +33,13 @@
<td class="<%- isExpired() ? 'text-danger' : '' %>"> <td class="<%- isExpired() ? 'text-danger' : '' %>">
<%- formatDbDate(expires_on, 'Do MMMM YYYY, h:mm a') %> <%- formatDbDate(expires_on, 'Do MMMM YYYY, h:mm a') %>
</td> </td>
<td>
<% if (active_domain_names().length > 0) { %>
<span class="status-icon bg-success"></span> <%- i18n('certificates', 'in-use') %>
<% } else { %>
<span class="status-icon bg-danger"></span> <%- i18n('certificates', 'inactive') %>
<% } %>
</td>
<% if (canManage) { %> <% if (canManage) { %>
<td class="text-right"> <td class="text-right">
<div class="item-action dropdown"> <div class="item-action dropdown">
@@ -48,6 +55,13 @@
<div class="dropdown-divider"></div> <div class="dropdown-divider"></div>
<% } %> <% } %>
<a href="#" class="delete dropdown-item"><i class="dropdown-icon fe fe-trash-2"></i> <%- i18n('str', 'delete') %></a> <a href="#" class="delete dropdown-item"><i class="dropdown-icon fe fe-trash-2"></i> <%- i18n('str', 'delete') %></a>
<% if (active_domain_names().length > 0) { %>
<div class="dropdown-divider"></div>
<span class="dropdown-header"><%- i18n('certificates', 'active-domain_names') %></span>
<% active_domain_names().forEach(function(host) { %>
<a href="https://<%- host %>" class="dropdown-item" target="_blank"><%- host %></a>
<% }); %>
<% } %>
</div> </div>
</div> </div>
</td> </td>

View File

@@ -44,14 +44,24 @@ module.exports = Mn.View.extend({
}, },
}, },
templateContext: { templateContext: function () {
return {
canManage: App.Cache.User.canManage('certificates'), canManage: App.Cache.User.canManage('certificates'),
isExpired: function () { isExpired: function () {
return moment(this.expires_on).isBefore(moment()); return moment(this.expires_on).isBefore(moment());
}, },
dns_providers: dns_providers dns_providers: dns_providers,
active_domain_names: function () {
const { proxy_hosts = [], redirect_hosts = [], dead_hosts = [] } = this;
return [...proxy_hosts, ...redirect_hosts, ...dead_hosts].reduce((acc, host) => {
acc.push(...(host.domain_names || []));
return acc;
}, []);
}
};
}, },
initialize: function () { initialize: function () {
this.listenTo(this.model, 'change', this.render); this.listenTo(this.model, 'change', this.render);
} }

View File

@@ -3,6 +3,7 @@
<th><%- i18n('str', 'name') %></th> <th><%- i18n('str', 'name') %></th>
<th><%- i18n('all-hosts', 'cert-provider') %></th> <th><%- i18n('all-hosts', 'cert-provider') %></th>
<th><%- i18n('str', 'expires') %></th> <th><%- i18n('str', 'expires') %></th>
<th><%- i18n('str', 'status') %></th>
<% if (canManage) { %> <% if (canManage) { %>
<th>&nbsp;</th> <th>&nbsp;</th>
<% } %> <% } %>

View File

@@ -74,7 +74,7 @@ module.exports = Mn.View.extend({
e.preventDefault(); e.preventDefault();
let query = this.ui.query.val(); let query = this.ui.query.val();
this.fetch(['owner'], query) this.fetch(['owner','proxy_hosts', 'dead_hosts', 'redirection_hosts'], query)
.then(response => this.showData(response)) .then(response => this.showData(response))
.catch(err => { .catch(err => {
this.showError(err); this.showError(err);
@@ -89,7 +89,7 @@ module.exports = Mn.View.extend({
onRender: function () { onRender: function () {
let view = this; let view = this;
view.fetch(['owner']) view.fetch(['owner','proxy_hosts', 'dead_hosts', 'redirection_hosts'])
.then(response => { .then(response => {
if (!view.isDestroyed()) { if (!view.isDestroyed()) {
if (response && response.length) { if (response && response.length) {

View File

@@ -3,8 +3,16 @@
<h5 class="modal-title"><%- i18n('streams', 'form-title', {id: id}) %></h5> <h5 class="modal-title"><%- i18n('streams', 'form-title', {id: id}) %></h5>
<button type="button" class="close cancel" aria-label="Close" data-dismiss="modal">&nbsp;</button> <button type="button" class="close cancel" aria-label="Close" data-dismiss="modal">&nbsp;</button>
</div> </div>
<div class="modal-body"> <div class="modal-body has-tabs">
<div class="alert alert-danger mb-0 rounded-0" id="le-error-info" role="alert"></div>
<form> <form>
<ul class="nav nav-tabs" role="tablist">
<li role="presentation" class="nav-item"><a href="#details" aria-controls="tab1" role="tab" data-toggle="tab" class="nav-link active"><i class="fe fe-zap"></i> <%- i18n('all-hosts', 'details') %></a></li>
<li role="presentation" class="nav-item"><a href="#ssl-options" aria-controls="tab2" role="tab" data-toggle="tab" class="nav-link"><i class="fe fe-shield"></i> <%- i18n('str', 'ssl') %></a></li>
</ul>
<div class="tab-content">
<!-- Details -->
<div role="tabpanel" class="tab-pane active" id="details">
<div class="row"> <div class="row">
<div class="col-sm-12 col-md-12"> <div class="col-sm-12 col-md-12">
<div class="form-group"> <div class="form-group">
@@ -46,6 +54,137 @@
<div class="forward-type-error invalid-feedback"><%- i18n('streams', 'forward-type-error') %></div> <div class="forward-type-error invalid-feedback"><%- i18n('streams', 'forward-type-error') %></div>
</div> </div>
</div> </div>
</div>
<!-- SSL -->
<div role="tabpanel" class="tab-pane" id="ssl-options">
<div class="row">
<div class="col-sm-12 col-md-12">
<div class="form-group">
<label class="form-label"><%- i18n('streams', 'ssl-certificate') %></label>
<select name="certificate_id" class="form-control custom-select" placeholder="<%- i18n('all-hosts', 'none') %>">
<option selected value="0" data-data="{&quot;id&quot;:0}" <%- certificate_id ? '' : 'selected' %>><%- i18n('all-hosts', 'none') %></option>
<option selected value="new" data-data="{&quot;id&quot;:&quot;new&quot;}"><%- i18n('all-hosts', 'new-cert') %></option>
</select>
</div>
</div>
<!-- DNS challenge -->
<div class="col-sm-12 col-md-12 letsencrypt">
<div class="form-group">
<label class="form-label"><%- i18n('all-hosts', 'domain-names') %> <span class="form-required">*</span></label>
<input type="text" name="domain_names" class="form-control" id="input-domains" value="<%- domain_names.join(',') %>">
</div>
<div class="form-group">
<label class="custom-switch">
<input
type="checkbox"
class="custom-switch-input"
name="meta[dns_challenge]"
value="1"
checked
disabled
>
<span class="custom-switch-indicator"></span>
<span class="custom-switch-description"><%= i18n('ssl', 'dns-challenge') %></span>
</label>
</div>
</div>
<div class="col-sm-12 col-md-12 letsencrypt">
<fieldset class="form-fieldset dns-challenge">
<div class="text-red mb-4"><i class="fe fe-alert-triangle"></i> <%= i18n('ssl', 'certbot-warning') %></div>
<!-- Certbot DNS plugin selection -->
<div class="row">
<div class="col-sm-12 col-md-12">
<div class="form-group">
<label class="form-label"><%- i18n('ssl', 'dns-provider') %> <span class="form-required">*</span></label>
<select
name="meta[dns_provider]"
id="dns_provider"
class="form-control custom-select"
>
<option
value=""
disabled
hidden
<%- getDnsProvider() === null ? 'selected' : '' %>
>Please Choose...</option>
<% _.each(dns_plugins, function(plugin_info, plugin_name){ %>
<option
value="<%- plugin_name %>"
<%- getDnsProvider() === plugin_name ? 'selected' : '' %>
><%- plugin_info.name %></option>
<% }); %>
</select>
</div>
</div>
</div>
<!-- Certbot credentials file content -->
<div class="row credentials-file-content">
<div class="col-sm-12 col-md-12">
<div class="form-group">
<label class="form-label"><%- i18n('ssl', 'credentials-file-content') %> <span class="form-required">*</span></label>
<textarea
name="meta[dns_provider_credentials]"
class="form-control text-monospace"
id="dns_provider_credentials"
><%- getDnsProviderCredentials() %></textarea>
<div class="text-secondary small">
<i class="fe fe-info"></i>
<%= i18n('ssl', 'credentials-file-content-info') %>
</div>
<div class="text-red small">
<i class="fe fe-alert-triangle"></i>
<%= i18n('ssl', 'stored-as-plaintext-info') %>
</div>
</div>
</div>
</div>
<!-- DNS propagation delay -->
<div class="row">
<div class="col-sm-12 col-md-12">
<div class="form-group mb-0">
<label class="form-label"><%- i18n('ssl', 'propagation-seconds') %></label>
<input
type="number"
min="0"
name="meta[propagation_seconds]"
class="form-control"
id="propagation_seconds"
value="<%- getPropagationSeconds() %>"
>
<div class="text-secondary small">
<i class="fe fe-info"></i>
<%= i18n('ssl', 'propagation-seconds-info') %>
</div>
</div>
</div>
</div>
</fieldset>
</div>
<!-- Lets encrypt -->
<div class="col-sm-12 col-md-12 letsencrypt">
<div class="form-group">
<label class="form-label"><%- i18n('ssl', 'letsencrypt-email') %> <span class="form-required">*</span></label>
<input name="meta[letsencrypt_email]" type="email" class="form-control" placeholder="" value="<%- getLetsencryptEmail() %>" required disabled>
</div>
</div>
<div class="col-sm-12 col-md-12 letsencrypt">
<div class="form-group">
<label class="custom-switch">
<input type="checkbox" class="custom-switch-input" name="meta[letsencrypt_agree]" value="1" required disabled>
<span class="custom-switch-indicator"></span>
<span class="custom-switch-description"><%= i18n('ssl', 'letsencrypt-agree', {url: 'https://letsencrypt.org/repository/'}) %> <span class="form-required">*</span></span>
</label>
</div>
</div>
</div>
</div>
</div>
</form> </form>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">

View File

@@ -2,10 +2,14 @@ const Mn = require('backbone.marionette');
const App = require('../../main'); const App = require('../../main');
const StreamModel = require('../../../models/stream'); const StreamModel = require('../../../models/stream');
const template = require('./form.ejs'); const template = require('./form.ejs');
const dns_providers = require('../../../../../global/certbot-dns-plugins');
require('jquery-serializejson'); require('jquery-serializejson');
require('jquery-mask-plugin'); require('jquery-mask-plugin');
require('selectize'); require('selectize');
const Helpers = require("../../../lib/helpers");
const certListItemTemplate = require("../certificates-list-item.ejs");
const i18n = require("../../i18n");
module.exports = Mn.View.extend({ module.exports = Mn.View.extend({
template: template, template: template,
@@ -18,7 +22,17 @@ module.exports = Mn.View.extend({
buttons: '.modal-footer button', buttons: '.modal-footer button',
switches: '.custom-switch-input', switches: '.custom-switch-input',
cancel: 'button.cancel', cancel: 'button.cancel',
save: 'button.save' save: 'button.save',
le_error_info: '#le-error-info',
certificate_select: 'select[name="certificate_id"]',
domain_names: 'input[name="domain_names"]',
dns_challenge_switch: 'input[name="meta[dns_challenge]"]',
dns_challenge_content: '.dns-challenge',
dns_provider: 'select[name="meta[dns_provider]"]',
credentials_file_content: '.credentials-file-content',
dns_provider_credentials: 'textarea[name="meta[dns_provider_credentials]"]',
propagation_seconds: 'input[name="meta[propagation_seconds]"]',
letsencrypt: '.letsencrypt'
}, },
events: { events: {
@@ -48,6 +62,35 @@ module.exports = Mn.View.extend({
data.tcp_forwarding = !!data.tcp_forwarding; data.tcp_forwarding = !!data.tcp_forwarding;
data.udp_forwarding = !!data.udp_forwarding; data.udp_forwarding = !!data.udp_forwarding;
if (typeof data.meta === 'undefined') data.meta = {};
data.meta.letsencrypt_agree = data.meta.letsencrypt_agree == 1;
data.meta.dns_challenge = true;
if (data.meta.propagation_seconds === '') data.meta.propagation_seconds = undefined;
if (typeof data.domain_names === 'string' && data.domain_names) {
data.domain_names = data.domain_names.split(',');
}
// Check for any domain names containing wildcards, which are not allowed with letsencrypt
if (data.certificate_id === 'new') {
let domain_err = false;
if (!data.meta.dns_challenge) {
data.domain_names.map(function (name) {
if (name.match(/\*/im)) {
domain_err = true;
}
});
}
if (domain_err) {
alert(i18n('ssl', 'no-wildcard-without-dns'));
return;
}
} else {
data.certificate_id = parseInt(data.certificate_id, 10);
}
let method = App.Api.Nginx.Streams.create; let method = App.Api.Nginx.Streams.create;
let is_new = true; let is_new = true;
@@ -70,10 +113,108 @@ module.exports = Mn.View.extend({
}); });
}) })
.catch(err => { .catch(err => {
alert(err.message); let more_info = '';
this.ui.buttons.prop('disabled', false).removeClass('btn-disabled'); if (err.code === 500 && err.debug) {
}); try {
more_info = JSON.parse(err.debug).debug.stack.join("\n");
} catch (e) {
} }
}
this.ui.le_error_info[0].innerHTML = `${err.message}${more_info !== '' ? `<pre class="mt-3">${more_info}</pre>` : ''}`;
this.ui.le_error_info.show();
this.ui.le_error_info[0].scrollIntoView();
this.ui.buttons.prop('disabled', false).removeClass('btn-disabled');
this.ui.save.removeClass('btn-loading');
});
},
'change @ui.certificate_select': function () {
let id = this.ui.certificate_select.val();
if (id === 'new') {
this.ui.letsencrypt.show().find('input').prop('disabled', false);
this.ui.domain_names.prop('required', 'required');
this.ui.dns_challenge_switch
.prop('disabled', true)
.parents('.form-group')
.css('opacity', 0.5);
this.ui.dns_provider.prop('required', 'required');
const selected_provider = this.ui.dns_provider[0].options[this.ui.dns_provider[0].selectedIndex].value;
if (selected_provider != '' && dns_providers[selected_provider].credentials !== false) {
this.ui.dns_provider_credentials.prop('required', 'required');
}
this.ui.dns_challenge_content.show();
} else {
this.ui.letsencrypt.hide().find('input').prop('disabled', true);
}
},
'change @ui.dns_provider': function () {
const selected_provider = this.ui.dns_provider[0].options[this.ui.dns_provider[0].selectedIndex].value;
if (selected_provider != '' && dns_providers[selected_provider].credentials !== false) {
this.ui.dns_provider_credentials.prop('required', 'required');
this.ui.dns_provider_credentials[0].value = dns_providers[selected_provider].credentials;
this.ui.credentials_file_content.show();
} else {
this.ui.dns_provider_credentials.prop('required', false);
this.ui.credentials_file_content.hide();
}
},
},
templateContext: {
getLetsencryptEmail: function () {
return App.Cache.User.get('email');
},
getDnsProvider: function () {
return typeof this.meta.dns_provider !== 'undefined' && this.meta.dns_provider != '' ? this.meta.dns_provider : null;
},
getDnsProviderCredentials: function () {
return typeof this.meta.dns_provider_credentials !== 'undefined' ? this.meta.dns_provider_credentials : '';
},
getPropagationSeconds: function () {
return typeof this.meta.propagation_seconds !== 'undefined' ? this.meta.propagation_seconds : '';
},
dns_plugins: dns_providers,
},
onRender: function () {
let view = this;
// Certificates
this.ui.le_error_info.hide();
this.ui.dns_challenge_content.hide();
this.ui.credentials_file_content.hide();
this.ui.letsencrypt.hide();
this.ui.certificate_select.selectize({
valueField: 'id',
labelField: 'nice_name',
searchField: ['nice_name', 'domain_names'],
create: false,
preload: true,
allowEmptyOption: true,
render: {
option: function (item) {
item.i18n = App.i18n;
item.formatDbDate = Helpers.formatDbDate;
return certListItemTemplate(item);
}
},
load: function (query, callback) {
App.Api.Nginx.Certificates.getAll()
.then(rows => {
callback(rows);
})
.catch(err => {
console.error(err);
callback();
});
},
onLoad: function () {
view.ui.certificate_select[0].selectize.setValue(view.model.get('certificate_id'));
}
});
}, },
initialize: function (options) { initialize: function (options) {

View File

@@ -16,7 +16,10 @@
</td> </td>
<td> <td>
<div> <div>
<% if (tcp_forwarding) { %> <% if (certificate) { %>
<span class="tag"><%- i18n('streams', 'tcp+ssl') %></span>
<% }
else if (tcp_forwarding) { %>
<span class="tag"><%- i18n('streams', 'tcp') %></span> <span class="tag"><%- i18n('streams', 'tcp') %></span>
<% } <% }
if (udp_forwarding) { %> if (udp_forwarding) { %>
@@ -24,6 +27,9 @@
<% } %> <% } %>
</div> </div>
</td> </td>
<td>
<div><%- certificate && certificate_id ? i18n('ssl', certificate.provider) : i18n('all-hosts', 'none') %></div>
</td>
<td> <td>
<% <%
var o = isOnline(); var o = isOnline();

View File

@@ -3,6 +3,7 @@
<th><%- i18n('streams', 'incoming-port') %></th> <th><%- i18n('streams', 'incoming-port') %></th>
<th><%- i18n('str', 'destination') %></th> <th><%- i18n('str', 'destination') %></th>
<th><%- i18n('streams', 'protocol') %></th> <th><%- i18n('streams', 'protocol') %></th>
<th><%- i18n('str', 'ssl') %></th>
<th><%- i18n('str', 'status') %></th> <th><%- i18n('str', 'status') %></th>
<% if (canManage) { %> <% if (canManage) { %>
<th>&nbsp;</th> <th>&nbsp;</th>

View File

@@ -88,7 +88,7 @@ module.exports = Mn.View.extend({
onRender: function () { onRender: function () {
let view = this; let view = this;
view.fetch(['owner']) view.fetch(['owner', 'certificate'])
.then(response => { .then(response => {
if (!view.isDestroyed()) { if (!view.isDestroyed()) {
if (response && response.length) { if (response && response.length) {

View File

@@ -25,6 +25,27 @@
<div class="invalid-feedback secret-error"></div> <div class="invalid-feedback secret-error"></div>
</div> </div>
</div> </div>
<div class="col-sm-12 col-md-12">
<label class="form-label mfa-label"><%- i18n('mfa', 'mfa') %></label>
<button type="button" class="btn btn-info mfa-add"><%- i18n('mfa', 'mfa-add') %></button>
<button type="button" class="btn btn-danger mfa-remove" style="display: none;"><%- i18n('mfa', 'mfa-remove') %></button>
<div class="mfa-remove-confirm-container" style="display: none;">
<div class="form-group">
<label class="form-label"><%- i18n('mfa', 'confirm-password') %></label>
<input name="mfa_password" type="password" class="form-control mfa-remove-password-field" placeholder="<%- i18n('mfa', 'enter-password') %>">
<div class="invalid-feedback mfa-error"></div>
</div>
<button type="button" class="btn btn-danger mfa-remove-confirm"><%- i18n('mfa', 'confirm-remove-mfa') %></button>
</div>
<p class="qr-instructions" style="display: none;"><%- i18n('mfa', 'mfa-setup-instruction') %></p>
<div class="mfa-validation-container" style="display: none;">
<label class="form-label"><%- i18n('mfa', 'mfa-token') %> <span class="form-required">*</span></label>
<input name="mfa_validation" type="text" class="form-control" placeholder="000000" value="">
<div class="invalid-feedback mfa-error"></div>
</div>
</div>
<% if (isAdmin() && !isSelf()) { %> <% if (isAdmin() && !isSelf()) { %>
<div class="col-sm-12 col-md-12"> <div class="col-sm-12 col-md-12">
<div class="form-label"><%- i18n('roles', 'title') %></div> <div class="form-label"><%- i18n('roles', 'title') %></div>

View File

@@ -14,7 +14,15 @@ module.exports = Mn.View.extend({
buttons: '.modal-footer button', buttons: '.modal-footer button',
cancel: 'button.cancel', cancel: 'button.cancel',
save: 'button.save', save: 'button.save',
error: '.secret-error' error: '.secret-error',
mfaError: '.mfa-error',
addMfa: '.mfa-add',
mfaValidation: '.mfa-validation-container',
qrInstructions: '.qr-instructions',
removeMfa: '.mfa-remove',
removeMfaConfirmContainer: '.mfa-remove-confirm-container',
removeMfaConfirm: '.mfa-remove-confirm',
removeMfaPassword: '.mfa-remove-password-field'
}, },
events: { events: {
@@ -25,6 +33,10 @@ module.exports = Mn.View.extend({
let view = this; let view = this;
let data = this.ui.form.serializeJSON(); let data = this.ui.form.serializeJSON();
let mfaToken = data.mfa_validation;
delete data.mfa_validation;
delete data.mfa_password;
let show_password = this.model.get('email') === 'admin@example.com'; let show_password = this.model.get('email') === 'admin@example.com';
// admin@example.com is not allowed // admin@example.com is not allowed
@@ -62,6 +74,19 @@ module.exports = Mn.View.extend({
} }
view.model.set(result); view.model.set(result);
if (mfaToken) {
return App.Api.Mfa.enable(mfaToken)
.then(() => result)
.catch(err => {
view.ui.mfaError.text(err.message).show();
err.mfaHandled = true;
return Promise.reject(err);
});
}
return result;
})
.then(result => {
App.UI.closeModal(function () { App.UI.closeModal(function () {
if (method === App.Api.Users.create) { if (method === App.Api.Users.create) {
// Show permissions dialog immediately // Show permissions dialog immediately
@@ -72,9 +97,50 @@ module.exports = Mn.View.extend({
}); });
}) })
.catch(err => { .catch(err => {
if (!err.mfaHandled) {
this.ui.error.text(err.message).show(); this.ui.error.text(err.message).show();
}
this.ui.buttons.prop('disabled', false).removeClass('btn-disabled'); this.ui.buttons.prop('disabled', false).removeClass('btn-disabled');
}); });
},
'click @ui.addMfa': function (e) {
let view = this;
App.Api.Mfa.create()
.then(response => {
view.ui.addMfa.replaceWith(`<img class="qr-code" src="${response.qrCode}" alt="QR Code">`);
view.ui.qrInstructions.show();
view.ui.mfaValidation.show();
// Add required attribute once MFA is activated
view.ui.mfaValidation.find('input[name="mfa_validation"]').attr('required', true);
})
.catch(err => {
view.ui.error.text(err.message).show();
});
},
'click @ui.removeMfa': function (e) {
// Show confirmation section with a password field and confirm button
this.ui.removeMfa.hide();
this.ui.removeMfaConfirmContainer.show();
},
'click @ui.removeMfaConfirm': function (e) {
let view = this;
let password = view.ui.removeMfaPassword.val();
if (!password) {
view.ui.error.text('Password required to remove MFA').show();
return;
}
App.Api.Mfa.delete(password)
.then(() => {
view.ui.addMfa.show();
view.ui.qrInstructions.hide();
view.ui.mfaValidation.hide();
view.ui.removeMfaConfirmContainer.hide();
view.ui.removeMfa.hide();
view.ui.mfaValidation.find('input[name="mfa_validation"]').removeAttr('required');
})
.catch(err => {
view.ui.mfaError.text(err.message).show();
});
} }
}, },
@@ -104,5 +170,30 @@ module.exports = Mn.View.extend({
if (typeof options.model === 'undefined' || !options.model) { if (typeof options.model === 'undefined' || !options.model) {
this.model = new UserModel.Model(); this.model = new UserModel.Model();
} }
},
onRender: function () {
let view = this;
App.Api.Mfa.check()
.then(response => {
if (response.active) {
view.ui.addMfa.hide();
view.ui.qrInstructions.hide();
view.ui.mfaValidation.hide();
view.ui.removeMfa.show();
view.ui.removeMfaConfirmContainer.hide();
view.ui.mfaValidation.find('input[name="mfa_validation"]').removeAttr('required');
} else {
view.ui.addMfa.show();
view.ui.qrInstructions.hide();
view.ui.mfaValidation.hide();
view.ui.removeMfa.hide();
view.ui.removeMfaConfirmContainer.hide();
view.ui.mfaValidation.find('input[name="mfa_validation"]').removeAttr('required');
}
})
.catch(err => {
view.ui.error.text(err.message).show();
});
} }
}); });

View File

@@ -37,8 +37,19 @@
"all": "All", "all": "All",
"any": "Any" "any": "Any"
}, },
"mfa": {
"mfa": "Multi Factor Authentication",
"mfa-add": "Add Multi Factor Authentication",
"mfa-remove": "Remove Multi Factor Authentication",
"mfa-setup-instruction": "Scan this QR code in your authenticator app to set up MFA and then enter the current MFA code in the input field.",
"mfa-token": "Multi factor authentication token",
"confirm-password": "Please enter your password to confirm",
"enter-password": "Enter Password",
"confirm-remove-mfa": "Confirm Multi Factor Authentication removal"
},
"login": { "login": {
"title": "Login to your account" "title": "Login to your account",
"mfa-required-text": "Please enter your MFA token to continue"
}, },
"main": { "main": {
"app": "Nginx Proxy Manager", "app": "Nginx Proxy Manager",
@@ -179,7 +190,9 @@
"delete-confirm": "Are you sure you want to delete this Stream?", "delete-confirm": "Are you sure you want to delete this Stream?",
"help-title": "What is a Stream?", "help-title": "What is a Stream?",
"help-content": "A relatively new feature for Nginx, a Stream will serve to forward TCP/UDP traffic directly to another computer on the network.\nIf you're running game servers, FTP or SSH servers this can come in handy.", "help-content": "A relatively new feature for Nginx, a Stream will serve to forward TCP/UDP traffic directly to another computer on the network.\nIf you're running game servers, FTP or SSH servers this can come in handy.",
"search": "Search Incoming Port…" "search": "Search Incoming Port…",
"ssl-certificate": "SSL Certificate for TCP Forwarding",
"tcp+ssl": "TCP+SSL"
}, },
"certificates": { "certificates": {
"title": "SSL Certificates", "title": "SSL Certificates",
@@ -206,7 +219,10 @@
"reachability-other": "There is a server found at this domain but it returned an unexpected status code {code}. Is it the NPM server? Please make sure your domain points to the IP where your NPM instance is running.", "reachability-other": "There is a server found at this domain but it returned an unexpected status code {code}. Is it the NPM server? Please make sure your domain points to the IP where your NPM instance is running.",
"download": "Download", "download": "Download",
"renew-title": "Renew Let's Encrypt Certificate", "renew-title": "Renew Let's Encrypt Certificate",
"search": "Search Certificate…" "search": "Search Certificate…",
"in-use" : "In use",
"inactive": "Inactive",
"active-domain_names": "Active domain names"
}, },
"access-lists": { "access-lists": {
"title": "Access Lists", "title": "Access Lists",

View File

@@ -1,3 +1,4 @@
<div class="container"> <div class="container">
<div class="row"> <div class="row">
<div class="col col-login mx-auto"> <div class="col col-login mx-auto">
@@ -24,6 +25,12 @@
<input name="secret" type="password" class="form-control" placeholder="<%- i18n('str', 'password') %>" required> <input name="secret" type="password" class="form-control" placeholder="<%- i18n('str', 'password') %>" required>
<div class="invalid-feedback secret-error"></div> <div class="invalid-feedback secret-error"></div>
</div> </div>
<div class="form-group mfa-group" style="display: none;">
<p class="mfa-info"><%- i18n('login', 'mfa-required-text') %>:</p>
<label class="form-label"><%- i18n('mfa', 'mfa-token') %></label>
<input name="mfa_token" type="text" class="form-control" placeholder="<%- i18n('mfa', 'mfa-token') %>">
<div class="invalid-feedback mfa-error"></div>
</div>
<div class="form-footer"> <div class="form-footer">
<button type="submit" class="btn btn-teal btn-block"><%- i18n('str', 'sign-in') %></button> <button type="submit" class="btn btn-teal btn-block"><%- i18n('str', 'sign-in') %></button>
</div> </div>

View File

@@ -13,7 +13,11 @@ module.exports = Mn.View.extend({
identity: 'input[name="identity"]', identity: 'input[name="identity"]',
secret: 'input[name="secret"]', secret: 'input[name="secret"]',
error: '.secret-error', error: '.secret-error',
button: 'button' error_mfa:'.mfa-error',
button: 'button',
mfaGroup: '.mfa-group', // added MFA group selector
mfaToken: 'input[name="mfa_token"]', // added MFA token input
mfaInfo: '.mfa-info' // added MFA info element
}, },
events: { events: {
@@ -22,15 +26,37 @@ module.exports = Mn.View.extend({
this.ui.button.addClass('btn-loading').prop('disabled', true); this.ui.button.addClass('btn-loading').prop('disabled', true);
this.ui.error.hide(); this.ui.error.hide();
if(this.ui.mfaToken.val()) {
Api.Tokens.loginWithMFA(this.ui.identity.val(), this.ui.secret.val(), this.ui.mfaToken.val(), true)
.then(() => {
window.location = '/';
})
.catch(err => {
if (err.message === 'Invalid MFA token.') {
this.ui.error_mfa.text(err.message).show();
} else {
this.ui.error.text(err.message).show();
}
this.ui.button.removeClass('btn-loading').prop('disabled', false);
});
} else {
Api.Tokens.login(this.ui.identity.val(), this.ui.secret.val(), true) Api.Tokens.login(this.ui.identity.val(), this.ui.secret.val(), true)
.then(() => { .then(() => {
window.location = '/'; window.location = '/';
}) })
.catch(err => { .catch(err => {
if (err.message === 'MFA token required') {
this.ui.mfaGroup.show();
this.ui.mfaInfo.show();
} else {
this.ui.error.text(err.message).show(); this.ui.error.text(err.message).show();
}
this.ui.button.removeClass('btn-loading').prop('disabled', false); this.ui.button.removeClass('btn-loading').prop('disabled', false);
}); });
} }
}
}, },
templateContext: { templateContext: {
@@ -40,3 +66,5 @@ module.exports = Mn.View.extend({
} }
} }
}); });

View File

@@ -15,8 +15,11 @@ const model = Backbone.Model.extend({
udp_forwarding: false, udp_forwarding: false,
enabled: true, enabled: true,
meta: {}, meta: {},
certificate_id: 0,
domain_names: [],
// The following are expansions: // The following are expansions:
owner: null owner: null,
certificate: null
}; };
} }
}); });

View File

@@ -215,6 +215,14 @@
"credentials": "# Gandi personal access token\ndns_gandi_token=PERSONAL_ACCESS_TOKEN", "credentials": "# Gandi personal access token\ndns_gandi_token=PERSONAL_ACCESS_TOKEN",
"full_plugin_name": "dns-gandi" "full_plugin_name": "dns-gandi"
}, },
"gcore": {
"name": "Gcore DNS",
"package_name": "certbot-dns-gcore",
"version": "~=0.1.8",
"dependencies": "",
"credentials": "dns_gcore_apitoken = 0123456789abcdef0123456789abcdef01234567",
"full_plugin_name": "dns-gcore"
},
"godaddy": { "godaddy": {
"name": "GoDaddy", "name": "GoDaddy",
"package_name": "certbot-dns-godaddy", "package_name": "certbot-dns-godaddy",
@@ -356,7 +364,7 @@
"package_name": "certbot-dns-mijn-host", "package_name": "certbot-dns-mijn-host",
"version": "~=0.0.4", "version": "~=0.0.4",
"dependencies": "", "dependencies": "",
"credentials": "dns-mijn-host-credentials = /etc/letsencrypt/mijnhost-credentials.ini", "credentials": "dns_mijn_host_api_key=0123456789abcdef0123456789abcdef",
"full_plugin_name": "dns-mijn-host" "full_plugin_name": "dns-mijn-host"
}, },
"namecheap": { "namecheap": {

View File

@@ -1,11 +1,22 @@
FROM cypress/included:13.9.0 FROM cypress/included:14.0.1
COPY --chown=1000 ./test /test
# Disable Cypress CLI colors # Disable Cypress CLI colors
ENV FORCE_COLOR=0 ENV FORCE_COLOR=0
ENV NO_COLOR=1 ENV NO_COLOR=1
# testssl.sh and mkcert
RUN wget "https://github.com/testssl/testssl.sh/archive/refs/tags/v3.2rc4.tar.gz" -O /tmp/testssl.tgz -q \
&& tar -xzf /tmp/testssl.tgz -C /tmp \
&& mv /tmp/testssl.sh-3.2rc4 /testssl \
&& rm /tmp/testssl.tgz \
&& apt-get update \
&& apt-get install -y bsdmainutils curl dnsutils \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/* \
&& wget "https://github.com/FiloSottile/mkcert/releases/download/v1.4.4/mkcert-v1.4.4-linux-amd64" -O /bin/mkcert \
&& chmod +x /bin/mkcert
COPY --chown=1000 ./test /test
WORKDIR /test WORKDIR /test
RUN yarn install && yarn cache clean RUN yarn install && yarn cache clean
ENTRYPOINT [] ENTRYPOINT []

View File

@@ -0,0 +1,213 @@
/// <reference types="cypress" />
describe('Streams', () => {
let token;
before(() => {
cy.getToken().then((tok) => {
token = tok;
// Set default site content
cy.task('backendApiPut', {
token: token,
path: '/api/settings/default-site',
data: {
value: 'html',
meta: {
html: '<p>yay it works</p>'
},
},
}).then((data) => {
cy.validateSwaggerSchema('put', 200, '/settings/{settingID}', data);
});
});
// Create a custom cert pair
cy.exec('mkcert -cert-file=/test/cypress/fixtures/website1.pem -key-file=/test/cypress/fixtures/website1.key.pem website1.example.com').then((result) => {
expect(result.code).to.eq(0);
// Install CA
cy.exec('mkcert -install').then((result) => {
expect(result.code).to.eq(0);
});
});
cy.exec('rm -f /test/results/testssl.json');
});
it('Should be able to create TCP Stream', function() {
cy.task('backendApiPost', {
token: token,
path: '/api/nginx/streams',
data: {
incoming_port: 1500,
forwarding_host: '127.0.0.1',
forwarding_port: 80,
certificate_id: 0,
meta: {
dns_provider_credentials: "",
letsencrypt_agree: false,
dns_challenge: true
},
tcp_forwarding: true,
udp_forwarding: false
}
}).then((data) => {
cy.validateSwaggerSchema('post', 201, '/nginx/streams', data);
expect(data).to.have.property('id');
expect(data.id).to.be.greaterThan(0);
expect(data).to.have.property('enabled', true);
expect(data).to.have.property('tcp_forwarding', true);
expect(data).to.have.property('udp_forwarding', false);
cy.exec('curl --noproxy -- http://website1.example.com:1500').then((result) => {
expect(result.code).to.eq(0);
expect(result.stdout).to.contain('yay it works');
});
});
});
it('Should be able to create UDP Stream', function() {
cy.task('backendApiPost', {
token: token,
path: '/api/nginx/streams',
data: {
incoming_port: 1501,
forwarding_host: '127.0.0.1',
forwarding_port: 80,
certificate_id: 0,
meta: {
dns_provider_credentials: "",
letsencrypt_agree: false,
dns_challenge: true
},
tcp_forwarding: false,
udp_forwarding: true
}
}).then((data) => {
cy.validateSwaggerSchema('post', 201, '/nginx/streams', data);
expect(data).to.have.property('id');
expect(data.id).to.be.greaterThan(0);
expect(data).to.have.property('enabled', true);
expect(data).to.have.property('tcp_forwarding', false);
expect(data).to.have.property('udp_forwarding', true);
});
});
it('Should be able to create TCP/UDP Stream', function() {
cy.task('backendApiPost', {
token: token,
path: '/api/nginx/streams',
data: {
incoming_port: 1502,
forwarding_host: '127.0.0.1',
forwarding_port: 80,
certificate_id: 0,
meta: {
dns_provider_credentials: "",
letsencrypt_agree: false,
dns_challenge: true
},
tcp_forwarding: true,
udp_forwarding: true
}
}).then((data) => {
cy.validateSwaggerSchema('post', 201, '/nginx/streams', data);
expect(data).to.have.property('id');
expect(data.id).to.be.greaterThan(0);
expect(data).to.have.property('enabled', true);
expect(data).to.have.property('tcp_forwarding', true);
expect(data).to.have.property('udp_forwarding', true);
cy.exec('curl --noproxy -- http://website1.example.com:1502').then((result) => {
expect(result.code).to.eq(0);
expect(result.stdout).to.contain('yay it works');
});
});
});
it('Should be able to create SSL TCP Stream', function() {
let certID = 0;
// Create custom cert
cy.task('backendApiPost', {
token: token,
path: '/api/nginx/certificates',
data: {
provider: "other",
nice_name: "Custom Certificate for SSL Stream",
},
}).then((data) => {
cy.validateSwaggerSchema('post', 201, '/nginx/certificates', data);
expect(data).to.have.property('id');
certID = data.id;
// Upload files
cy.task('backendApiPostFiles', {
token: token,
path: `/api/nginx/certificates/${certID}/upload`,
files: {
certificate: 'website1.pem',
certificate_key: 'website1.key.pem',
},
}).then((data) => {
cy.validateSwaggerSchema('post', 200, '/nginx/certificates/{certID}/upload', data);
expect(data).to.have.property('certificate');
expect(data).to.have.property('certificate_key');
// Create the stream
cy.task('backendApiPost', {
token: token,
path: '/api/nginx/streams',
data: {
incoming_port: 1503,
forwarding_host: '127.0.0.1',
forwarding_port: 80,
certificate_id: certID,
meta: {
dns_provider_credentials: "",
letsencrypt_agree: false,
dns_challenge: true
},
tcp_forwarding: true,
udp_forwarding: false
}
}).then((data) => {
cy.validateSwaggerSchema('post', 201, '/nginx/streams', data);
expect(data).to.have.property('id');
expect(data.id).to.be.greaterThan(0);
expect(data).to.have.property("enabled", true);
expect(data).to.have.property('tcp_forwarding', true);
expect(data).to.have.property('udp_forwarding', false);
expect(data).to.have.property('certificate_id', certID);
// Check the ssl termination
cy.task('log', '[testssl.sh] Running ...');
cy.exec('/testssl/testssl.sh --quiet --add-ca="$(/bin/mkcert -CAROOT)/rootCA.pem" --jsonfile=/test/results/testssl.json website1.example.com:1503', {
timeout: 120000, // 2 minutes
}).then((result) => {
cy.task('log', '[testssl.sh] ' + result.stdout);
const allowedSeverities = ["INFO", "OK", "LOW", "MEDIUM"];
const ignoredIDs = [
'cert_chain_of_trust',
'cert_extlifeSpan',
'cert_revocation',
'overall_grade',
];
cy.readFile('/test/results/testssl.json').then((data) => {
// Parse each array item
for (let i = 0; i < data.length; i++) {
const item = data[i];
if (ignoredIDs.includes(item.id)) {
continue;
}
expect(item.severity).to.be.oneOf(allowedSeverities);
}
});
});
});
});
});
});
});

View File

@@ -4,18 +4,18 @@
"description": "", "description": "",
"main": "index.js", "main": "index.js",
"dependencies": { "dependencies": {
"@jc21/cypress-swagger-validation": "^0.3.1", "@jc21/cypress-swagger-validation": "^0.3.2",
"axios": "^1.7.7", "axios": "^1.7.9",
"cypress": "^13.15.0", "cypress": "^14.0.1",
"cypress-multi-reporters": "^1.6.4", "cypress-multi-reporters": "^2.0.5",
"cypress-wait-until": "^3.0.2", "cypress-wait-until": "^3.0.2",
"eslint": "^9.12.0", "eslint": "^9.19.0",
"eslint-plugin-align-assignments": "^1.1.2", "eslint-plugin-align-assignments": "^1.1.2",
"eslint-plugin-chai-friendly": "^1.0.1", "eslint-plugin-chai-friendly": "^1.0.1",
"eslint-plugin-cypress": "^3.5.0", "eslint-plugin-cypress": "^4.1.0",
"form-data": "^4.0.1", "form-data": "^4.0.1",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"mocha": "^10.7.3", "mocha": "^11.1.0",
"mocha-junit-reporter": "^2.2.1" "mocha-junit-reporter": "^2.2.1"
}, },
"scripts": { "scripts": {