wildduck/api.js

627 lines
20 KiB
JavaScript
Raw Normal View History

2017-03-06 22:13:40 +08:00
'use strict';
2017-07-16 19:37:33 +08:00
const config = require('wild-config');
2017-03-06 22:13:40 +08:00
const restify = require('restify');
const log = require('npmlog');
2017-12-23 00:18:50 +08:00
const logger = require('restify-logger');
2023-03-10 17:16:43 +08:00
const corsMiddleware = require('restify-cors-middleware2');
const UserHandler = require('./lib/user-handler');
2017-07-21 02:33:41 +08:00
const MailboxHandler = require('./lib/mailbox-handler');
2017-07-21 21:29:57 +08:00
const MessageHandler = require('./lib/message-handler');
2019-03-26 20:17:43 +08:00
const StorageHandler = require('./lib/storage-handler');
2019-09-29 20:00:44 +08:00
const AuditHandler = require('./lib/audit-handler');
const ImapNotifier = require('./lib/imap-notifier');
const db = require('./lib/db');
const certs = require('./lib/certs');
2018-08-29 18:15:38 +08:00
const tools = require('./lib/tools');
2019-04-05 20:08:46 +08:00
const consts = require('./lib/consts');
2018-08-29 18:15:38 +08:00
const crypto = require('crypto');
2018-10-18 15:37:32 +08:00
const Gelf = require('gelf');
const os = require('os');
2018-11-14 19:32:40 +08:00
const util = require('util');
const ObjectId = require('mongodb').ObjectId;
2021-05-20 01:25:47 +08:00
const tls = require('tls');
2021-07-06 03:31:25 +08:00
const Lock = require('ioredfour');
const Path = require('path');
2023-03-20 18:34:06 +08:00
const errors = require('restify-errors');
2017-07-26 16:52:55 +08:00
const acmeRoutes = require('./lib/api/acme');
2017-07-26 16:52:55 +08:00
const usersRoutes = require('./lib/api/users');
const addressesRoutes = require('./lib/api/addresses');
const mailboxesRoutes = require('./lib/api/mailboxes');
const messagesRoutes = require('./lib/api/messages');
2019-03-26 20:17:43 +08:00
const storageRoutes = require('./lib/api/storage');
2017-07-26 16:52:55 +08:00
const filtersRoutes = require('./lib/api/filters');
2020-09-28 18:45:41 +08:00
const domainaccessRoutes = require('./lib/api/domainaccess');
2017-07-26 16:52:55 +08:00
const aspsRoutes = require('./lib/api/asps');
2017-10-10 16:19:10 +08:00
const totpRoutes = require('./lib/api/2fa/totp');
const custom2faRoutes = require('./lib/api/2fa/custom');
2022-03-06 06:08:48 +08:00
const webauthnRoutes = require('./lib/api/2fa/webauthn');
2017-07-26 16:52:55 +08:00
const updatesRoutes = require('./lib/api/updates');
const authRoutes = require('./lib/api/auth');
2017-07-30 23:07:35 +08:00
const autoreplyRoutes = require('./lib/api/autoreply');
2017-11-29 19:59:58 +08:00
const submitRoutes = require('./lib/api/submit');
2019-09-28 02:26:17 +08:00
const auditRoutes = require('./lib/api/audit');
2017-12-01 21:04:32 +08:00
const domainaliasRoutes = require('./lib/api/domainaliases');
2017-12-28 19:45:02 +08:00
const dkimRoutes = require('./lib/api/dkim');
const certsRoutes = require('./lib/api/certs');
2020-10-09 16:08:33 +08:00
const webhooksRoutes = require('./lib/api/webhooks');
const settingsRoutes = require('./lib/api/settings');
const healthRoutes = require('./lib/api/health');
const { SettingsHandler } = require('./lib/settings-handler');
2017-03-06 22:13:40 +08:00
const { RestifyApiGenerate } = require('restifyapigenerate');
const Joi = require('joi');
const restifyApiGenerateConfig = require('./config/apigeneration.json');
const restifyApiGenerate = new RestifyApiGenerate(Joi, __dirname);
2018-10-18 15:37:32 +08:00
let userHandler;
let mailboxHandler;
let messageHandler;
2019-03-26 20:17:43 +08:00
let storageHandler;
2019-09-29 20:00:44 +08:00
let auditHandler;
let settingsHandler;
2018-10-18 15:37:32 +08:00
let notifier;
let loggelf;
2017-07-17 21:32:31 +08:00
const serverOptions = {
2018-01-02 21:04:01 +08:00
name: 'WildDuck API',
strictRouting: true,
2018-09-10 18:09:57 +08:00
maxParamLength: 196,
formatters: {
'application/json; q=0.4': (req, res, body) => {
2017-07-20 21:10:36 +08:00
let data = body ? JSON.stringify(body, false, 2) + '\n' : 'null';
2018-10-18 15:37:32 +08:00
let size = Buffer.byteLength(data);
res.setHeader('Content-Length', size);
if (!body) {
return data;
}
let path = (req.route && req.route.path) || (req.url || '').replace(/(accessToken=)[^&]+/, '$1xxxxxx');
let message = {
2018-11-15 16:30:53 +08:00
short_message: 'HTTP [' + req.method + ' ' + path + '] ' + (body.success ? 'OK' : 'FAILED'),
2018-10-18 15:37:32 +08:00
2020-04-08 16:57:48 +08:00
_remote_ip: req.headers['x-forwarded-for'] || req.connection.remoteAddress,
2020-07-16 16:15:04 +08:00
_ip: ((req.params && req.params.ip) || '').toString().substr(0, 40) || '',
_sess: ((req.params && req.params.sess) || '').toString().substr(0, 40) || '',
2018-10-18 15:37:32 +08:00
2018-10-19 15:15:16 +08:00
_http_route: path,
_http_method: req.method,
2018-10-18 15:37:32 +08:00
_user: req.user,
_role: req.role,
2018-11-15 16:30:53 +08:00
_api_response: body.success ? 'success' : 'fail',
2018-10-18 15:37:32 +08:00
_error: body.error,
_code: body.code,
2018-10-18 15:37:32 +08:00
_size: size
};
Object.keys(req.params || {}).forEach(key => {
let value = req.params[key];
2018-11-14 19:32:40 +08:00
if (!value && value !== 0) {
// if falsy don't continue, allow 0 integer as value
2018-10-18 15:37:32 +08:00
return;
}
2018-10-18 15:57:41 +08:00
// cast value to string
value = util.inspect(req.params[key], false, 3).trim();
2018-10-18 15:37:32 +08:00
if (['password'].includes(key)) {
value = '***';
2018-11-14 19:32:40 +08:00
} else if (value.length > 128) {
value = value.substr(0, 128) + '…';
2018-10-18 15:37:32 +08:00
}
2018-10-18 15:57:41 +08:00
2018-10-18 15:37:32 +08:00
if (key.length > 30) {
key = key.substr(0, 30) + '…';
}
2018-10-18 15:57:41 +08:00
2018-10-18 15:37:32 +08:00
message['_req_' + key] = value;
});
Object.keys(body).forEach(key => {
2018-10-18 15:57:41 +08:00
let value = body[key];
if (!body || !['id'].includes(key)) {
2018-10-18 15:37:32 +08:00
return;
}
2020-04-08 16:57:48 +08:00
value = typeof value === 'string' ? value : util.inspect(value, false, 3).toString().trim();
2018-11-14 19:32:40 +08:00
if (value.length > 128) {
value = value.substr(0, 128) + '…';
2018-10-18 15:37:32 +08:00
}
2018-10-18 15:57:41 +08:00
2018-10-18 15:37:32 +08:00
if (key.length > 30) {
key = key.substr(0, 30) + '…';
}
2018-10-18 15:57:41 +08:00
2018-10-18 15:37:32 +08:00
message['_res_' + key] = value;
});
loggelf(message);
return data;
}
}
2017-07-17 21:32:31 +08:00
};
let certOptions = {};
certs.loadTLSOptions(certOptions, 'api');
if (config.api.secure && certOptions.key) {
2021-05-16 01:29:11 +08:00
let httpsServerOptions = {};
2021-05-20 01:54:18 +08:00
httpsServerOptions.key = certOptions.key;
2022-08-16 20:22:47 +08:00
httpsServerOptions.cert = tools.buildCertChain(certOptions.cert, certOptions.ca);
2021-05-20 01:25:47 +08:00
let defaultSecureContext = tls.createSecureContext(httpsServerOptions);
2021-05-16 01:29:11 +08:00
httpsServerOptions.SNICallback = (servername, cb) => {
certs
2021-10-08 22:30:15 +08:00
.getContextForServername(
servername,
httpsServerOptions,
{
source: 'API'
},
{
loggelf: message => loggelf(message)
}
)
2021-09-04 02:32:39 +08:00
.then(context => {
cb(null, context || defaultSecureContext);
})
2021-05-16 01:29:11 +08:00
.catch(err => cb(err));
};
serverOptions.httpsServerOptions = httpsServerOptions;
2017-07-17 21:32:31 +08:00
}
const server = restify.createServer(serverOptions);
2017-03-06 22:13:40 +08:00
2021-04-01 18:50:26 +08:00
const cors = corsMiddleware({
2021-04-01 18:53:52 +08:00
origins: [].concat(config.api.cors.origins || ['*']),
allowHeaders: ['X-Access-Token', 'Authorization'],
2021-04-01 18:50:26 +08:00
allowCredentialsAllOrigins: true
});
server.pre(cors.preflight);
server.use(cors.actual);
// disable compression for EventSource response
// this needs to be called before gzipResponse
server.use(async (req, res) => {
if (res && req.route.path === '/users/:user/updates') {
req.headers['accept-encoding'] = '';
}
});
2017-12-23 00:18:50 +08:00
2020-07-16 16:15:04 +08:00
server.use(
restify.plugins.queryParser({
allowDots: true,
mapParams: true
})
);
2017-06-03 14:51:58 +08:00
server.use(
2017-07-11 03:15:16 +08:00
restify.plugins.bodyParser({
2017-06-03 14:51:58 +08:00
maxBodySize: 0,
mapParams: true,
mapFiles: true,
2017-06-03 14:51:58 +08:00
overrideParams: false
})
);
2017-03-06 22:13:40 +08:00
2021-09-04 20:32:27 +08:00
// public files
server.get(
{ name: 'public_get', path: '/public/*', excludeRoute: true },
restify.plugins.serveStatic({
directory: Path.join(__dirname, 'public'),
default: 'index.html'
})
);
2023-01-09 17:58:27 +08:00
// Disable GZIP as it does not work with stream.pipe(res)
//server.use(restify.plugins.gzipResponse());
2021-09-04 20:32:27 +08:00
server.use(async (req, res) => {
if (['public_get', 'public_post', 'acmeToken'].includes(req.route.name)) {
// skip token check for public pages
return;
}
2021-09-04 20:32:27 +08:00
let accessToken =
req.query.accessToken ||
req.headers['x-access-token'] ||
(req.headers.authorization ? req.headers.authorization.replace(/^Bearer\s+/i, '').trim() : false) ||
false;
2018-08-28 19:37:06 +08:00
if (req.query.accessToken) {
// delete or it will conflict with Joi schemes
delete req.query.accessToken;
}
2018-08-29 18:15:38 +08:00
if (req.params.accessToken) {
// delete or it will conflict with Joi schemes
delete req.params.accessToken;
}
2020-07-17 19:21:54 +08:00
if (req.headers['x-access-token']) {
req.headers['x-access-token'] = '';
}
2018-08-28 19:37:06 +08:00
if (req.headers.authorization) {
req.headers.authorization = '';
}
let tokenRequired = false;
2018-08-29 18:15:38 +08:00
let fail = () => {
2023-03-20 18:34:06 +08:00
let error = new errors.ForbiddenError(
{
2023-03-20 18:36:27 +08:00
code: 'InvalidToken'
2023-03-20 18:34:06 +08:00
},
2023-03-20 18:36:27 +08:00
'Invalid accessToken value'
2023-03-20 18:34:06 +08:00
);
2023-03-20 18:20:02 +08:00
throw error;
};
req.validate = permission => {
if (!permission.granted) {
let err = new Error('Not enough privileges');
err.responseCode = 403;
err.code = 'MissingPrivileges';
throw err;
}
};
// hard coded master token
if (config.api.accessToken) {
tokenRequired = true;
if (config.api.accessToken === accessToken) {
req.role = 'root';
req.user = 'root';
return;
}
}
2018-08-29 18:15:38 +08:00
if (config.api.accessControl.enabled || accessToken) {
tokenRequired = true;
if (accessToken && accessToken.length === 40 && /^[a-fA-F0-9]{40}$/.test(accessToken)) {
let tokenData;
let tokenHash = crypto.createHash('sha256').update(accessToken).digest('hex');
try {
let key = 'tn:token:' + tokenHash;
tokenData = await db.redis.hgetall(key);
} catch (err) {
err.responseCode = 500;
err.code = 'InternalDatabaseError';
2018-08-29 18:15:38 +08:00
throw err;
}
2018-08-28 19:37:06 +08:00
if (tokenData && tokenData.user && tokenData.role && config.api.roles[tokenData.role]) {
let signData;
if ('authVersion' in tokenData) {
// cast value to number
tokenData.authVersion = Number(tokenData.authVersion) || 0;
signData = {
token: accessToken,
user: tokenData.user,
authVersion: tokenData.authVersion,
role: tokenData.role
};
} else {
signData = {
token: accessToken,
user: tokenData.user,
role: tokenData.role
};
2018-08-29 18:15:38 +08:00
}
let signature = crypto.createHmac('sha256', config.api.accessControl.secret).update(JSON.stringify(signData)).digest('hex');
2018-08-29 18:15:38 +08:00
if (signature !== tokenData.s) {
// rogue token or invalidated secret
/*
// do not delete just in case there is something wrong with the check
2018-08-29 18:15:38 +08:00
try {
await db.redis
.multi()
.del('tn:token:' + tokenHash)
.exec();
} catch (err) {
// ignore
}
*/
} else if (tokenData.ttl && !isNaN(tokenData.ttl) && Number(tokenData.ttl) > 0) {
let tokenTTL = Number(tokenData.ttl);
let tokenLifetime = config.api.accessControl.tokenLifetime || consts.ACCESS_TOKEN_MAX_LIFETIME;
// check if token is not too old
if ((Date.now() - Number(tokenData.created)) / 1000 < tokenLifetime) {
// token is still usable, increase session length
try {
await db.redis
.multi()
.expire('tn:token:' + tokenHash, tokenTTL)
.exec();
} catch (err) {
// ignore
}
2018-08-29 18:15:38 +08:00
req.role = tokenData.role;
req.user = tokenData.user;
2019-03-21 16:30:32 +08:00
// make a reference to original method, otherwise might be overrided
let setAuthToken = userHandler.setAuthToken.bind(userHandler);
2019-03-21 16:30:32 +08:00
req.accessToken = {
hash: tokenHash,
user: tokenData.user,
// if called then refreshes token data for current hash
update: async () => setAuthToken(tokenData.user, accessToken)
};
} else {
// expired token, clear it
try {
await db.redis
.multi()
.del('tn:token:' + tokenHash)
.exec();
} catch (err) {
// ignore
}
2019-04-05 20:27:15 +08:00
}
} else {
req.role = tokenData.role;
req.user = tokenData.user;
}
if (req.params && req.params.user === 'me' && /^[0-9a-f]{24}$/i.test(req.user)) {
req.params.user = req.user;
}
if (!req.role) {
return fail();
}
2019-04-05 20:27:15 +08:00
if (/^[0-9a-f]{24}$/i.test(req.user)) {
let tokenAuthVersion = Number(tokenData.authVersion) || 0;
let userData = await db.users.collection('users').findOne(
{
_id: new ObjectId(req.user)
},
{ projection: { authVersion: true } }
);
let userAuthVersion = Number(userData && userData.authVersion) || 0;
if (!userData || tokenAuthVersion < userAuthVersion) {
// unknown user or expired session
try {
/*
// do not delete just in case there is something wrong with the check
await db.redis
.multi()
.del('tn:token:' + tokenHash)
.exec();
*/
} catch (err) {
// ignore
}
return fail();
}
2018-08-29 18:15:38 +08:00
}
2023-03-20 18:20:02 +08:00
// pass
return;
2018-08-29 18:15:38 +08:00
}
}
}
2018-08-28 19:37:06 +08:00
if (tokenRequired) {
// no valid token found
return fail();
}
2018-08-29 18:15:38 +08:00
// allow all
req.role = 'root';
req.user = 'root';
});
2017-07-25 20:50:16 +08:00
2020-07-16 16:15:04 +08:00
logger.token('user-ip', req => ((req.params && req.params.ip) || '').toString().substr(0, 40) || '-');
logger.token('user-sess', req => (req.params && req.params.sess) || '-');
2018-08-29 18:15:38 +08:00
logger.token('user', req => (req.user && req.user.toString()) || '-');
2018-08-28 19:37:06 +08:00
logger.token('url', req => {
if (/\baccessToken=/.test(req.url)) {
return req.url.replace(/\baccessToken=[^&]+/g, 'accessToken=' + 'x'.repeat(6));
}
return req.url;
});
2017-12-23 00:18:50 +08:00
server.use(
logger(':remote-addr :user [:user-ip/:user-sess] :method :url :status :time-spent :append', {
2017-12-23 00:18:50 +08:00
stream: {
write: message => {
message = (message || '').toString();
if (message) {
log.http('API', message.replace('\n', '').trim());
}
}
}
})
);
module.exports = done => {
2018-06-18 21:29:10 +08:00
if (!config.api.enabled) {
2017-04-13 16:35:39 +08:00
return setImmediate(() => done(null, false));
}
let started = false;
2018-10-18 15:37:32 +08:00
const component = config.log.gelf.component || 'wildduck';
const hostname = config.log.gelf.hostname || os.hostname();
const gelf =
config.log.gelf && config.log.gelf.enabled
? new Gelf(config.log.gelf.options)
: {
// placeholder
emit: (key, message) => log.info('Gelf', JSON.stringify(message))
2018-10-18 15:37:32 +08:00
};
loggelf = (message, requiredKeys = []) => {
2018-10-18 15:37:32 +08:00
if (typeof message === 'string') {
message = {
short_message: message
};
}
message = message || {};
2018-10-18 16:53:14 +08:00
if (!message.short_message || message.short_message.indexOf(component.toUpperCase()) !== 0) {
message.short_message = component.toUpperCase() + ' ' + (message.short_message || '');
}
2018-10-18 15:37:32 +08:00
message.facility = component; // facility is deprecated but set by the driver if not provided
message.host = hostname;
message.timestamp = Date.now() / 1000;
message._component = component;
Object.keys(message).forEach(key => {
if (!message[key] && !requiredKeys.includes(key)) {
// remove the key if it empty/falsy/undefined/null and it is not required to stay
2018-10-18 15:37:32 +08:00
delete message[key];
}
});
gelf.emit('gelf.log', message);
};
notifier = new ImapNotifier({
database: db.database,
redis: db.redis
});
2017-08-07 02:25:10 +08:00
messageHandler = new MessageHandler({
database: db.database,
users: db.users,
redis: db.redis,
gridfs: db.gridfs,
2018-10-18 15:37:32 +08:00
attachments: config.attachments,
loggelf: message => loggelf(message)
});
2017-08-07 02:25:10 +08:00
2019-03-26 20:17:43 +08:00
storageHandler = new StorageHandler({
database: db.database,
users: db.users,
gridfs: db.gridfs,
loggelf: message => loggelf(message)
});
userHandler = new UserHandler({
database: db.database,
users: db.users,
redis: db.redis,
2017-08-08 19:35:18 +08:00
messageHandler,
2018-10-18 15:37:32 +08:00
loggelf: message => loggelf(message)
});
2017-08-07 02:25:10 +08:00
mailboxHandler = new MailboxHandler({
database: db.database,
users: db.users,
redis: db.redis,
2018-10-18 15:37:32 +08:00
notifier,
loggelf: message => loggelf(message)
});
2019-09-29 20:00:44 +08:00
auditHandler = new AuditHandler({
database: db.database,
users: db.users,
gridfs: db.gridfs,
bucket: 'audit',
loggelf: message => loggelf(message)
});
settingsHandler = new SettingsHandler({ db: db.database });
server.loggelf = (message, requiredKeys = []) => loggelf(message, requiredKeys);
2018-10-18 15:37:32 +08:00
2021-07-06 03:31:25 +08:00
server.lock = new Lock({
redis: db.redis,
namespace: 'mail'
});
2022-08-16 20:22:47 +08:00
acmeRoutes(db, server, { disableRedirect: true });
usersRoutes(db, server, userHandler, settingsHandler);
addressesRoutes(db, server, userHandler, settingsHandler);
2017-07-26 16:52:55 +08:00
mailboxesRoutes(db, server, mailboxHandler);
messagesRoutes(db, server, messageHandler, userHandler, storageHandler, settingsHandler);
2019-03-26 20:17:43 +08:00
storageRoutes(db, server, storageHandler);
filtersRoutes(db, server, userHandler, settingsHandler);
2020-09-28 18:45:41 +08:00
domainaccessRoutes(db, server);
2017-07-26 16:52:55 +08:00
aspsRoutes(db, server, userHandler);
2017-10-10 16:19:10 +08:00
totpRoutes(db, server, userHandler);
custom2faRoutes(db, server, userHandler);
2022-03-06 06:08:48 +08:00
webauthnRoutes(db, server, userHandler);
2017-07-26 16:52:55 +08:00
updatesRoutes(db, server, notifier);
authRoutes(db, server, userHandler);
2017-07-30 23:07:35 +08:00
autoreplyRoutes(db, server);
submitRoutes(db, server, messageHandler, userHandler, settingsHandler);
2019-09-29 20:00:44 +08:00
auditRoutes(db, server, auditHandler);
2017-12-01 21:04:32 +08:00
domainaliasRoutes(db, server);
2017-12-28 19:45:02 +08:00
dkimRoutes(db, server);
certsRoutes(db, server);
2020-10-09 16:08:33 +08:00
webhooksRoutes(db, server);
settingsRoutes(db, server, settingsHandler);
healthRoutes(db, server, loggelf);
2017-07-26 16:52:55 +08:00
if (process.env.NODE_ENV === 'test') {
server.get(
{ name: 'api-methods', path: '/api-methods' },
tools.responseWrapper(async (req, res) => {
res.charSet('utf-8');
return res.json(server.router.getRoutes());
})
);
}
if (process.env.GENERATE_API_DOCS === 'true') {
server.pre(restifyApiGenerate.restifyApiGenerate(server, restifyApiGenerateConfig));
}
if (process.env.REGENERATE_API_DOCS === 'true') {
// allow 2.5 seconds for services to start and the api doc to be generated, after that exit process
(async function () {
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
await sleep(2500);
process.exit(0);
})();
}
feat(apidocs): Autogenerate OpenAPI docs ZMS-100 (#552) * api.js added endpoint for generating openapi docs. added new info to one route in mailboxes.js and messages.js files so that the api docs generation can be done at all * try to first generate json representation of the api docs * add initial Joi Object parsing * api.js make generation dynamic. messages.js add schemas from separate file. messages-schemas.js used for messages endpoint schemas * add additions to schemas. Add new schemas to messages.js and also add response object there. Add response object parsing functionality to api.js * add initial openapi doc yml file generation * remove manual yaml parsing with js-yaml JSON -> YAML parsing * fix replaceWithRefs and parseComponentsDecoupled functions, refactor, remove unnecessary comments and logs * add support for another endpoint * move big code from api.js to tools * fix array type representation, fix response objects, add necessary data and changes to endpoints * redo include logic into exclude login * fix api generation in tools.js to accomodate new naming of objects * fix messages.js, add structuredClone check in tools.js * fix structured clone definition * add one endpoint in messages.js to the api generation * messages.js add one more endpoint to API generation * add response to prev commit. Add new endpoint to API generation. Archive message and archive messages * finish with post endpoints in messages.js * added general request and response schemas. Also added req and res schemas for messages * add multiple GET endpoints to API generation and changed them to new design. Use general schemas made earlier * fix incorrect import of successRes * fix mailboxes.js * refactor general-schemas.js. Fix searchSchema in messages.js. Mailboxes.js fix response * tools.js rename methodObj in API generation to operationObj * tools.js api generation remove string fallbacks * messages.js finish with GET endpoints, addition to API doc generation * for openApi doc generation use JSON now instead of YAML
2023-11-10 23:55:16 +08:00
server.on('error', err => {
if (!started) {
started = true;
return done(err);
}
log.error('API', err);
});
2022-11-24 19:21:10 +08:00
server.on('restifyError', (req, res, err, callback) => {
if (!started) {
started = true;
return done(err);
}
return callback();
});
server.listen(config.api.port, config.api.host, () => {
if (started) {
return server.close();
}
started = true;
log.info('API', 'Server listening on %s:%s', config.api.host || '0.0.0.0', config.api.port);
done(null, server);
2017-03-06 22:13:40 +08:00
});
};