diff --git a/lib/api/certs.js b/lib/api/certs.js index f65ff9fd..1a0ea9b8 100644 --- a/lib/api/certs.js +++ b/lib/api/certs.js @@ -9,6 +9,7 @@ const TaskHandler = require('../task-handler'); const tools = require('../tools'); const roles = require('../roles'); const { nextPageCursorSchema, previousPageCursorSchema, pageNrSchema, sessSchema, sessIPSchema, booleanSchema } = require('../schemas'); +const { successRes, totalRes, pageRes, previousCursorRes, nextCursorRes } = require('../schemas/response/general-schemas'); const certificateSchema = Joi.string() .empty('') @@ -33,19 +34,76 @@ module.exports = (db, server) => { }); server.get( - { name: 'cert', path: '/certs' }, + { + name: 'cert', + path: '/certs', + summary: 'List registered TLS certificates', + tags: ['Certs'], + validationObjs: { + requestBody: {}, + queryParams: { + query: Joi.string().empty('').trim().max(255).example('example.com').description('Partial match of a server name'), + altNames: booleanSchema.default(false).description('Match `query` value against SAN as well (including wildcard names)').example('true'), + limit: Joi.number().default(20).min(1).max(250).description('How many records to return'), + next: nextPageCursorSchema, + previous: previousPageCursorSchema, + page: pageNrSchema, + sess: sessSchema, + ip: sessIPSchema + }, + pathParams: {}, + response: { + 200: { + description: 'Success', + model: Joi.object({ + success: successRes, + total: totalRes, + page: pageRes, + previousCursor: previousCursorRes, + nextCursor: nextCursorRes, + results: Joi.array() + .items( + Joi.object({ + id: Joi.string().required().description('ID of the certificate').example('609d201236d1d936948f23b1'), + servername: Joi.string() + .required() + .description('The server name this certificate applies to') + .example('imap.example.com'), + acme: booleanSchema + .required() + .description('If true then private key and certificate are managed automatically by ACME'), + description: Joi.string().required().description('Key description').example('Some notes about this certificate'), + fingerprint: Joi.string() + .required() + .description('Key fingerprint (SHA1)') + .example('59:8b:ed:11:5b:4f:ce:b4:e5:1a:2f:35:b1:6f:7d:93:40:c8:2f:9c:38:3b:cd:f4:04:92:a1:0e:17:2c:3f:f3'), + created: Joi.date().required().description('Datestring').example('2024-03-13T20:06:46.179Z'), + expires: Joi.date().required().description('Certificate expiration time').example('2024-04-26T21:55:55.000Z'), + altNames: Joi.array() + .items(Joi.string().required()) + .required() + .description('SAN servernames listed in the certificate') + .example(['example.com', 'www.example.com']) + }) + .required() + .description('Certificate listing') + .$_setFlag('objectName', 'GetTLSCertResult') + ) + .required() + }) + } + } + } + }, tools.responseWrapper(async (req, res) => { res.charSet('utf-8'); - const schema = Joi.object().keys({ - query: Joi.string().empty('').trim().max(255), - altNames: booleanSchema.default(false), - limit: Joi.number().default(20).min(1).max(250), - next: nextPageCursorSchema, - previous: previousPageCursorSchema, - page: pageNrSchema, - sess: sessSchema, - ip: sessIPSchema + const { requestBody, queryParams, pathParams } = req.route.spec.validationObjs; + + const schema = Joi.object({ + ...requestBody, + ...queryParams, + ...pathParams }); const result = schema.validate(req.params, { @@ -155,14 +213,39 @@ module.exports = (db, server) => { ); server.get( - '/certs/resolve/:servername', + { + path: '/certs/resolve/:servername', + summary: 'Resolve ID for a server name', + tags: ['Certs'], + validationObjs: { + requestBody: {}, + queryParams: { + sess: sessSchema, + ip: sessIPSchema + }, + pathParams: { + servername: Joi.string().hostname().description('Server name').required().example('example.com') + }, + response: { + 200: { + description: 'Success', + model: Joi.object({ + success: successRes, + id: Joi.string().required().description('Unique ID of the cert (24 byte hex)').example('609d201236d1d936948f23b1') + }) + } + } + } + }, tools.responseWrapper(async (req, res) => { res.charSet('utf-8'); - const schema = Joi.object().keys({ - servername: Joi.string().hostname(), - sess: sessSchema, - ip: sessIPSchema + const { requestBody, queryParams, pathParams } = req.route.spec.validationObjs; + + const schema = Joi.object({ + ...requestBody, + ...queryParams, + ...pathParams }); const result = schema.validate(req.params, { @@ -219,51 +302,99 @@ module.exports = (db, server) => { ); server.post( - '/certs', + { + path: '/certs', + summary: 'Create or update TLS certificate for server name', + tags: ['Certs'], + description: + 'Add a new TLS certificate for a server name or update existing one. You can add a single certificate for each server name but SAN names are supported as well. For example you can add a sertificate for "mydomain.com" that includes "*.mydomain.com" in SAN and the same certificate would be used for requests that do not have it\'s own server name registered but match the SAN value.\n> NB! you must ensure yourself that the `servername` value is actually listed in certificate\'s common name or SAN as WildDuck is going to use this certificate regardless.', + validationObjs: { + pathParams: {}, + queryParams: {}, + requestBody: { + servername: Joi.string().empty('').hostname().required().label('ServerName').description('Server name this TLS certificate applies to'), + + privateKey: Joi.string() + .when('acme', { + switch: [ + { + is: false, + then: privateKeySchema.required() + }, + { + is: true, + then: privateKeySchema.required().optional() + } + ] + }) + .description('PEM formatted TLS private key. Optional if certificate is managed by ACME') + .label('PrivateKey'), + + cert: Joi.string() + .when('acme', { + switch: [ + { + is: false, + then: certificateSchema.required() + }, + { + is: true, + then: certificateSchema.optional() + } + ] + }) + .description( + 'PEM formatted TLS certificate or a certificate bundle with concatenated certificate and CA chain. Optional if certificate is managed by ACME' + ) + .label('Certificate'), + + ca: Joi.array() + .items(certificateSchema.label('CACert')) + .label('CACertList') + .description('CA chain certificates. Not needed if `cert` value is a bundle'), + + description: Joi.string().empty('').max(1024).trim().label('Description').description('Certificate description'), + acme: booleanSchema + .default(false) + .label('ACMEManaged') + .description('If true then private key and certificate are managed automatically by ACME'), + + sess: sessSchema, + ip: sessIPSchema + }, + response: { + 200: { + description: 'Success', + model: Joi.object({ + success: successRes, + id: Joi.string().required().description('ID of the certificate').example('609d201236d1d936948f23b1'), + servername: Joi.string().required().description('The server name this certificate applies to').example('imap.example.com'), + description: Joi.string().required().description('Key description').example('Some notes about this certificate'), + fingerprint: Joi.string() + .required() + .description('Key fingerprint (SHA1)') + .example('59:8b:ed:11:5b:4f:ce:b4:e5:1a:2f:35:b1:6f:7d:93:40:c8:2f:9c:38:3b:cd:f4:04:92:a1:0e:17:2c:3f:f3'), + expires: Joi.date().required().description('Certificate expiration time').example('2024-06-26T21:55:55.000Z'), + altNames: Joi.array() + .items(Joi.string().required()) + .required() + .description('SAN servernames listed in the certificate') + .example(['example.com', 'www.example.com']), + acme: booleanSchema.required().description('If true then private key and certificate are managed automatically by ACME') + }) + } + } + } + }, tools.responseWrapper(async (req, res) => { res.charSet('utf-8'); - const schema = Joi.object().keys({ - servername: Joi.string().empty('').hostname().required().label('ServerName'), + const { requestBody, queryParams, pathParams } = req.route.spec.validationObjs; - privateKey: Joi.string() - .when('acme', { - switch: [ - { - is: false, - then: privateKeySchema.required() - }, - { - is: true, - then: privateKeySchema.required().optional() - } - ] - }) - - .label('PrivateKey'), - - cert: Joi.string() - .when('acme', { - switch: [ - { - is: false, - then: certificateSchema.required() - }, - { - is: true, - then: certificateSchema.optional() - } - ] - }) - .label('Certificate'), - - ca: Joi.array().items(certificateSchema.label('CACert')).label('CACertList'), - - description: Joi.string().empty('').max(1024).trim().label('Description'), - acme: booleanSchema.default(false).label('ACMEManaged'), - - sess: sessSchema, - ip: sessIPSchema + const schema = Joi.object({ + ...requestBody, + ...queryParams, + ...pathParams }); const result = schema.validate(req.params, { @@ -318,14 +449,54 @@ module.exports = (db, server) => { ); server.get( - '/certs/:cert', + { + path: '/certs/:cert', + summary: 'Request TLS certificate information', + tags: ['Certs'], + validationObjs: { + requestBody: {}, + queryParams: { + sess: sessSchema, + ip: sessIPSchema + }, + pathParams: { + cert: Joi.string().hex().lowercase().length(24).required().description('ID of the TLS certificate').example('609d201236d1d936948f23b1') + }, + response: { + 200: { + description: 'Success', + model: Joi.object({ + success: successRes, + id: Joi.string().required().description('ID of the certificate').example('609d201236d1d936948f23b1'), + servername: Joi.string().required().description('The server name this certificate applies to').example('imap.example.com'), + description: Joi.string().required().description('Key description').example('Some notes about this certificate'), + fingerprint: Joi.string() + .required() + .description('Key fingerprint (SHA1)') + .example('59:8b:ed:11:5b:4f:ce:b4:e5:1a:2f:35:b1:6f:7d:93:40:c8:2f:9c:38:3b:cd:f4:04:92:a1:0e:17:2c:3f:f3'), + expires: Joi.date().required().description('Certificate expiration time').example('2024-06-26T21:55:55.000Z'), + created: Joi.date().required().description('Created datestring').example('2024-05-13T20:06:46.179Z'), + altNames: Joi.array() + .items(Joi.string().required()) + .required() + .description('SAN servernames listed in the certificate') + .example(['example.com', 'www.example.com']), + acme: booleanSchema.required().description('If true then private key and certificate are managed automatically by ACME'), + hasCert: booleanSchema.required().description('True if certificate actually has the certificate or private key') + }) + } + } + } + }, tools.responseWrapper(async (req, res) => { res.charSet('utf-8'); - const schema = Joi.object().keys({ - cert: Joi.string().hex().lowercase().length(24).required(), - sess: sessSchema, - ip: sessIPSchema + const { requestBody, queryParams, pathParams } = req.route.spec.validationObjs; + + const schema = Joi.object({ + ...requestBody, + ...queryParams, + ...pathParams }); const result = schema.validate(req.params, { @@ -370,6 +541,8 @@ module.exports = (db, server) => { if (response) { response.success = true; + } else { + response.success = false; } return res.json(response); @@ -377,14 +550,38 @@ module.exports = (db, server) => { ); server.del( - '/certs/:certs', + { + path: '/certs/:cert', + summary: 'Delete a TLS certificate', + tags: ['Certs'], + validationObjs: { + requestBody: {}, + queryParams: { + sess: sessSchema, + ip: sessIPSchema + }, + pathParams: { + cert: Joi.string().hex().lowercase().length(24).required().description('ID of the TLS certificate').example('609d201236d1d936948f23b1') + }, + response: { + 200: { + description: 'Success', + model: Joi.object({ + success: successRes + }) + } + } + } + }, tools.responseWrapper(async (req, res) => { res.charSet('utf-8'); - const schema = Joi.object().keys({ - certs: Joi.string().hex().lowercase().length(24).required(), - sess: sessSchema, - ip: sessIPSchema + const { requestBody, queryParams, pathParams } = req.route.spec.validationObjs; + + const schema = Joi.object({ + ...requestBody, + ...queryParams, + ...pathParams }); const result = schema.validate(req.params, { @@ -404,12 +601,12 @@ module.exports = (db, server) => { // permissions check req.validate(roles.can(req.role).deleteAny('certs')); - let certs = new ObjectId(result.value.certs); + let cert = new ObjectId(result.value.cert); let response; try { - response = await certHandler.del({ _id: certs }); + response = await certHandler.del({ _id: cert }); } catch (err) { switch (err.code) { case 'InputValidationError': diff --git a/lib/cert-handler.js b/lib/cert-handler.js index 4577dae1..41525294 100644 --- a/lib/cert-handler.js +++ b/lib/cert-handler.js @@ -440,7 +440,7 @@ class CertHandler { expires: certData.expires, altNames: certData.altNames, acme: !!certData.acme, - hasCert: (!!certData.privateKe && certData.cert) || false, + hasCert: (!!certData.privateKey && certData.cert) || false, created: certData.created }; diff --git a/lib/schemas.js b/lib/schemas.js index 1aa7d454..9da57058 100644 --- a/lib/schemas.js +++ b/lib/schemas.js @@ -85,10 +85,14 @@ const metaDataValidator = () => (value, helpers) => { const mongoCursorSchema = Joi.string().trim().empty('').custom(mongoCursorValidator({}), 'Cursor validation').max(1024); const pageLimitSchema = Joi.number().default(20).min(1).max(250).label('Page size'); const pageNrSchema = Joi.number().default(1).label('Page number').description('Current page number. Informational only, page numbers start from 1'); -const nextPageCursorSchema = mongoCursorSchema.label('Next page cursor').description('Cursor value for next page, retrieved from nextCursor response value'); +const nextPageCursorSchema = mongoCursorSchema + .label('Next page cursor') + .description('Cursor value for next page, retrieved from nextCursor response value') + .example('eyIkb2lkIjoiNWRmMWZkMmQ3NzkyNTExOGI2MDdjNjg0In0'); const previousPageCursorSchema = mongoCursorSchema .label('Previous page cursor') - .description('Cursor value for previous page, retrieved from previousCursor response value'); + .description('Cursor value for previous page, retrieved from previousCursor response value') + .example('TMIjjIy23ZGM2kk0lIixygWomEknQDWdmzMNIkbNeO0NNjR'); const booleanSchema = Joi.boolean().empty('').truthy('Y', 'true', 'yes', 'on', '1', 1).falsy('N', 'false', 'no', 'off', '0', 0); const metaDataSchema = Joi.any().custom(metaDataValidator({}), 'metadata validation'); diff --git a/lib/schemas/response/general-schemas.js b/lib/schemas/response/general-schemas.js index 1b969349..8a8b2c72 100644 --- a/lib/schemas/response/general-schemas.js +++ b/lib/schemas/response/general-schemas.js @@ -3,17 +3,19 @@ const Joi = require('joi'); const { booleanSchema } = require('../../schemas'); -const successRes = booleanSchema.required().description('Indicates successful response'); -const totalRes = Joi.number().required().description('How many results were found'); -const pageRes = Joi.number().required().description('Current page number. Derived from page query argument'); +const successRes = booleanSchema.required().description('Indicates successful response').example(true); +const totalRes = Joi.number().required().description('How many results were found').example(541); +const pageRes = Joi.number().required().description('Current page number. Derived from page query argument').example(1); const previousCursorRes = Joi.alternatives() .try(Joi.string(), booleanSchema) .required() - .description('Either a cursor string or false if there are not any previous results'); + .description('Either a cursor string or false if there are not any previous results') + .example('eyIkb2lkIjoiNWRmMWZkMmQ3NzkyNTExOGI2MDdjNjg0In0'); const nextCursorRes = Joi.alternatives() .try(Joi.string(), booleanSchema) .required() - .description('Either a cursor string or false if there are not any next results'); + .description('Either a cursor string or false if there are not any next results') + .example('TMIjjIy23ZGM2kk0lIixygWomEknQDWdmzMNIkbNeO0NNjR'); const quotaRes = Joi.object({ allowed: Joi.number().required().description('Allowed quota of the user in bytes'),