Compare commits

...

20 Commits

Author SHA1 Message Date
Dave Horton
5350f7bea0 bugfix: adding account-level speech credential with platform owner api key 2021-08-30 12:37:08 -04:00
Dave Horton
446cc57e09 when deleting a service provider, delete the associated speech_credentials and voip_carriers 2021-08-27 13:42:09 -04:00
Dave Horton
9525cf5a36 bugfix: queue event hook was getting set to register hook 2021-08-25 19:09:00 -04:00
Dave Horton
43393a2e4a Merge branch 'main' of github.com:jambonz/jambonz-api-server into main 2021-08-23 14:17:52 -04:00
Dave Horton
a06bba60e6 bugfix: setting a registration hook cleared out the queue event hook, and vice-versa 2021-08-23 14:17:41 -04:00
Brandon Lee Kitajchuk
318a8f0822 Fix incorrect operationId for MS Tenants :PUT method (#8) 2021-08-16 13:33:08 -04:00
Dave Horton
ecdf9898f8 bugfix: generating new account failed due to null webhook_secret 2021-08-16 08:25:19 -04:00
Dave Horton
e0bacb55e7 add support for queue_event_hook 2021-08-15 13:55:01 -04:00
Dave Horton
0eb365ea58 bugfix: dont require name from oauth profile 2021-08-05 16:57:10 -04:00
Dave Horton
f7fcbd4c7c add limits for adding account-level resources 2021-08-04 07:49:44 -04:00
Dave Horton
bc3b5bb1dc add form-urlencoded to package.json 2021-08-01 14:10:50 -04:00
Dave Horton
a5a759940b add APIs to retrieve pcaps from homer 2021-07-29 13:58:49 -04:00
Dave Horton
6c01d28288 LICENSE 2021-07-21 12:36:48 -04:00
Dave Horton
a3b9727d64 bugfix: selecting FS to handle createMessage api 2021-07-07 09:52:45 -04:00
Dave Horton
ac4ea4b265 reset_admin_password - add option for specifying initial password 2021-07-01 13:55:04 -04:00
Dave Horton
ec6d2d310a lint fix 2021-06-28 13:03:10 -04:00
Dave Horton
7b9390be50 bugfix: prevent an account level api key from creating an admin-level api key 2021-06-28 13:00:35 -04:00
Dave Horton
0589328f24 when provisioning a new account on hosted system, automatically add hello-world and dial-time apps 2021-06-28 10:03:54 -04:00
Dave Horton
f66814fff2 bugfix: reset admin password 2021-06-26 19:29:54 -04:00
Dave Horton
a79f77934e fix bug with seeding predefined carriers 2021-06-26 17:55:57 -04:00
28 changed files with 763 additions and 84 deletions

5
.gitignore vendored
View File

@@ -43,4 +43,7 @@ create_db.sql
.vscode
.env.*
.env
.env
test/postgres-data
db/remove-account.sh

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2021 Drachtio Communications Services, LLC
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -0,0 +1,40 @@
#!/usr/bin/env node
const crypto = require('crypto');
const {promisePool} = require('../lib/db');
const sql = 'INSERT INTO beta_invite_codes (invite_code) VALUES (?);';
const rand_string = (n) => {
if (n <= 0) {
return '';
}
var rs = '';
try {
rs = crypto.randomBytes(Math.ceil(n/2)).toString('hex').slice(0,n);
/* note: could do this non-blocking, but still might fail */
}
catch (ex) {
/* known exception cause: depletion of entropy info for randomBytes */
console.error('Exception generating random string: ' + ex);
/* weaker random fallback */
rs = '';
var r = n % 8, q = (n - r) / 8, i;
for (i = 0; i < q; i++) {
rs += Math.random().toString(16).slice(2);
}
if (r > 0) {
rs += Math.random().toString(16).slice(2, i);
}
}
return rs;
};
const doIt = async(len) => {
for (let i = 0; i < 50; i++) {
const val = rand_string(len).toUpperCase();
await promisePool.execute(sql, [val]);
}
process.exit(0);
};
doIt(6);

View File

@@ -1,5 +1,4 @@
#!/usr/bin/env node
console.log('reset_admin_password');
const {promisePool} = require('../lib/db');
const uuidv4 = require('uuid/v4');
const {generateHashedPassword} = require('../lib/utils/password-utils');
@@ -7,18 +6,22 @@ const sqlInsert = `INSERT into users
(user_sid, name, email, hashed_password, force_change, provider, email_validated)
values (?, ?, ?, ?, ?, ?, ?)
`;
const sqlChangeAdminToken = `UPDATE api_keys set token = ?
WHERE account_sid IS NULL
AND service_provider_sid IS NULL`;
const sqlQueryAccount = 'SELECT * from accounts LIMIT 1';
const sqlAddAccountAdminToken = `INSERT into api_keys (api_key_sid, token, account_sid)
const sqlInsertAdminToken = `INSERT into api_keys
(api_key_sid, token)
values (?, ?)`;
const sqlQueryAccount = 'SELECT * from accounts LEFT JOIN api_keys ON api_keys.account_sid = accounts.account_sid';
const sqlAddAccountToken = `INSERT into api_keys (api_key_sid, token, account_sid)
VALUES (?, ?, ?)`;
const password = process.env.JAMBONES_ADMIN_INITIAL_PASSWORD || 'admin';
console.log(`reset_admin_password, initial admin password is ${password}`);
const doIt = async() => {
const passwordHash = await generateHashedPassword('admin');
const passwordHash = await generateHashedPassword(password);
const sid = uuidv4();
await promisePool.execute('DELETE from users where name = "admin"');
await promisePool.execute(sqlInsert,
await promisePool.execute('DELETE from api_keys where account_sid is null and service_provider_sid is null');
await promisePool.execute(sqlInsert,
[
sid,
'admin',
@@ -29,16 +32,16 @@ const doIt = async() => {
1
]
);
/* reset admin token */
const uuid = uuidv4();
await promisePool.query(sqlChangeAdminToken, [uuid]);
await promisePool.execute(sqlInsertAdminToken, [uuidv4(), uuidv4()]);
/* create admin token for single account */
const api_key_sid = uuidv4();
const token = uuidv4();
const [r] = await promisePool.query(sqlQueryAccount);
await promisePool.execute(sqlAddAccountAdminToken, [api_key_sid, token, r[0].account_sid]);
const [r] = await promisePool.query({sql: sqlQueryAccount, nestTables: true});
if (1 === r.length && r[0].api_keys.api_key_sid === null) {
const api_key_sid = uuidv4();
const token = uuidv4();
const {account_sid} = r[0].accounts;
await promisePool.execute(sqlAddAccountToken, [api_key_sid, token, account_sid]);
}
process.exit(0);
};

View File

@@ -73,19 +73,6 @@ VALUES
('7bae60b3-4237-4baa-a711-30ea3bce19d8', '032d90d5-39e8-41c0-b807-9c88cffba65c', '185.47.148.45', 32, 5060, 1, 0),
('bc933522-18a2-47d8-9ae4-9faa8de4e927', '032d90d5-39e8-41c0-b807-9c88cffba65c', 'outbound.voxbone.com', 32, 5060, 0, 1);
-- Peerless gateways
insert into predefined_sip_gateways (predefined_sip_gateway_sid, predefined_carrier_sid, ipv4, netmask, port, inbound, outbound)
VALUES
('4e23f698-a70a-4616-9bf0-c9dd5ab123af', '17479288-bb9f-421a-89d1-f4ac57af1dca', '208.79.54.182', 32, 5060, 1, 0),
('e5c71c18-0511-41b8-bed9-1ba061bbcf10', '17479288-bb9f-421a-89d1-f4ac57af1dca', '208.79.52.192', 32, 5060, 0, 1),
('226c7471-2f4f-440f-8525-37fd0512bd8b', '17479288-bb9f-421a-89d1-f4ac57af1dca', '208.79.54.185', 32, 5060, 0, 1);
-- 382com gateways
insert into predefined_sip_gateways (predefined_sip_gateway_sid, predefined_carrier_sid, ipv4, netmask, port, inbound, outbound)
VALUES
('23e4c250-8578-4d88-99b5-a7941a58e26f', 'bdf70650-5328-47aa-b3d0-47cb219d9c6e', '64.125.111.10', 32, 5060, 1, 0),
('c726d435-c9a7-4c37-b891-775990a54638', 'bdf70650-5328-47aa-b3d0-47cb219d9c6e', '64.124.67.11', 32, 5060, 0, 1);
-- simwood gateways
insert into predefined_sip_gateways (predefined_sip_gateway_sid, predefined_carrier_sid, ipv4, netmask, port, inbound, outbound)
VALUES

View File

@@ -51,19 +51,6 @@ VALUES
('7bae60b3-4237-4baa-a711-30ea3bce19d8', '032d90d5-39e8-41c0-b807-9c88cffba65c', '185.47.148.45', 32, 5060, 1, 0),
('bc933522-18a2-47d8-9ae4-9faa8de4e927', '032d90d5-39e8-41c0-b807-9c88cffba65c', 'outbound.voxbone.com', 32, 5060, 0, 1);
-- Peerless gateways
insert into predefined_sip_gateways (predefined_sip_gateway_sid, predefined_carrier_sid, ipv4, netmask, port, inbound, outbound)
VALUES
('4e23f698-a70a-4616-9bf0-c9dd5ab123af', '17479288-bb9f-421a-89d1-f4ac57af1dca', '208.79.54.182', 32, 5060, 1, 0),
('e5c71c18-0511-41b8-bed9-1ba061bbcf10', '17479288-bb9f-421a-89d1-f4ac57af1dca', '208.79.52.192', 32, 5060, 0, 1),
('226c7471-2f4f-440f-8525-37fd0512bd8b', '17479288-bb9f-421a-89d1-f4ac57af1dca', '208.79.54.185', 32, 5060, 0, 1);
-- 382com gateways
insert into predefined_sip_gateways (predefined_sip_gateway_sid, predefined_carrier_sid, ipv4, netmask, port, inbound, outbound)
VALUES
('23e4c250-8578-4d88-99b5-a7941a58e26f', 'bdf70650-5328-47aa-b3d0-47cb219d9c6e', '64.125.111.10', 32, 5060, 1, 0),
('c726d435-c9a7-4c37-b891-775990a54638', 'bdf70650-5328-47aa-b3d0-47cb219d9c6e', '64.124.67.11', 32, 5060, 0, 1);
-- simwood gateways
insert into predefined_sip_gateways (predefined_sip_gateway_sid, predefined_carrier_sid, ipv4, netmask, port, inbound, outbound)
VALUES

View File

@@ -7,7 +7,9 @@ const {encrypt} = require('../utils/encrypt-decrypt');
const retrieveSql = `SELECT * from accounts acc
LEFT JOIN webhooks AS rh
ON acc.registration_hook_sid = rh.webhook_sid`;
ON acc.registration_hook_sid = rh.webhook_sid
LEFT JOIN webhooks AS qh
ON acc.queue_event_hook_sid = qh.webhook_sid`;
const insertPendingAccountSubscriptionSql = `INSERT account_subscriptions
(account_subscription_sid, account_sid, pending, stripe_subscription_id,
@@ -55,12 +57,23 @@ AND pending = 0`;
function transmogrifyResults(results) {
return results.map((row) => {
const obj = row.acc;
/* registration hook */
if (row.rh && Object.keys(row.rh).length && row.rh.url !== null) {
Object.assign(obj, {registration_hook: row.rh});
delete obj.registration_hook.webhook_sid;
}
else obj.registration_hook = null;
delete obj.registration_hook_sid;
/* queue event hook */
if (row.qh && Object.keys(row.qh).length && row.qh.url !== null) {
Object.assign(obj, {queue_event_hook: row.qh});
delete obj.queue_event_hook.webhook_sid;
}
else obj.queue_event_hook = null;
delete obj.queue_event_hook_sid;
return obj;
});
}
@@ -248,6 +261,10 @@ Account.fields = [
name: 'sip_realm',
type: 'string',
},
{
name: 'queue_event_hook_sid',
type: 'string',
},
{
name: 'registration_hook_sid',
type: 'string',

View File

@@ -212,7 +212,7 @@ async function validateCreateCall(logger, sid, req) {
async function validateCreateMessage(logger, sid, req) {
const obj = req.body;
//const {lookupAccountByPhoneNumber} = req.app.locals;
logger.debug({payload: req.body}, 'validateCreateMessage');
if (req.user.account_sid !== sid) {
throw new DbErrorBadRequest(`unauthorized createMessage request for account ${sid}`);
@@ -253,6 +253,9 @@ async function validateAdd(req) {
if (req.body.registration_hook && typeof req.body.registration_hook !== 'object') {
throw new DbErrorBadRequest('\'registration_hook\' must be an object when adding an account');
}
if (req.body.queue_event_hook && typeof req.body.queue_event_hook !== 'object') {
throw new DbErrorBadRequest('\'queue_event_hook\' must be an object when adding an account');
}
}
async function validateUpdate(req, sid) {
if (req.user.hasAccountAuth && req.user.account_sid !== sid) {
@@ -291,12 +294,12 @@ router.post('/', async(req, res) => {
await validateAdd(req);
// create webhooks if provided
const obj = Object.assign({webhook_secret: secret}, req.body);
for (const prop of ['registration_hook']) {
if (obj[prop]) {
const obj = {...req.body, webhook_secret: secret};
for (const prop of ['registration_hook', 'queue_event_hook']) {
if (obj[prop] && obj[prop].url && obj[prop].url.length > 0) {
obj[`${prop}_sid`] = await Webhook.make(obj[prop]);
delete obj[prop];
}
delete obj[prop];
}
logger.debug(`Attempting to add account ${JSON.stringify(obj)}`);
@@ -361,12 +364,14 @@ router.put('/:sid', async(req, res) => {
// create webhooks if provided
const obj = Object.assign({}, req.body);
if (null !== obj.registration_hook) {
for (const prop of ['registration_hook']) {
if (prop in obj && Object.keys(obj[prop]).length) {
for (const prop of ['registration_hook', 'queue_event_hook']) {
if (prop in obj) {
if (null === obj[prop] || !obj[prop].url || 0 === obj[prop].url.length) {
obj[`${prop}_sid`] = null;
}
else if (typeof obj[prop] === 'object') {
if ('webhook_sid' in obj[prop]) {
const sid = obj[prop]['webhook_sid'];
delete obj[prop]['webhook_sid'];
await Webhook.update(sid, obj[prop]);
}
else {
@@ -374,30 +379,35 @@ router.put('/:sid', async(req, res) => {
obj[`${prop}_sid`] = sid;
}
}
else {
obj[`${prop}_sid`] = null;
}
delete obj[prop];
}
}
await validateUpdate(req, sid);
if (Object.keys(obj).length) {
let orphanedHook;
let orphanedRegHook, orphanedQueueHook;
if (null === obj.registration_hook) {
const results = await Account.retrieve(sid);
if (results.length && results[0].registration_hook_sid) orphanedHook = results[0].registration_hook_sid;
if (results.length && results[0].registration_hook_sid) orphanedRegHook = results[0].registration_hook_sid;
obj.registration_hook_sid = null;
delete obj.registration_hook;
}
logger.info({obj}, `about to update Account ${sid}`);
if (null === obj.queue_event_hook) {
const results = await Account.retrieve(sid);
if (results.length && results[0].queue_event_hook_sid) orphanedQueueHook = results[0].queue_event_hook_sid;
obj.queue_event_hook_sid = null;
}
delete obj.registration_hook;
delete obj.queue_event_hook;
const rowsAffected = await Account.update(sid, obj);
if (rowsAffected === 0) {
return res.status(404).end();
}
if (orphanedHook) {
await Webhook.remove(orphanedHook);
if (orphanedRegHook) {
await Webhook.remove(orphanedRegHook);
}
if (orphanedQueueHook) {
await Webhook.remove(orphanedQueueHook);
}
}
@@ -642,7 +652,9 @@ router.post('/:sid/Messages', async(req, res) => {
logger.info('No available feature servers to handle createMessage API request');
return res.json({msg: 'no available feature servers at this time'}).status(500);
}
const ip = fs[idx++ % fs.length];
let ip = fs[idx++ % fs.length];
const arr = /^(.*):\d+$/.exec(ip);
if (arr) ip = arr[1];
logger.info({fs}, `feature servers available for createMessage API request, selecting ${ip}`);
const serviceUrl = `http://${ip}:3000/v1/createMessage/${account_sid}`;
await validateCreateMessage(logger, account_sid, req);

View File

@@ -4,7 +4,6 @@ const ApiKey = require('../../models/api-key');
const Account = require('../../models/account');
const decorate = require('./decorate');
const uuidv4 = require('uuid/v4');
const assert = require('assert');
const sysError = require('../error');
const preconditions = {
'add': validateAddToken,
@@ -71,10 +70,7 @@ async function validateDeleteToken(req, sid) {
router.post('/', async(req, res) => {
const logger = req.app.locals.logger;
try {
if ('add' in preconditions) {
assert(typeof preconditions.add === 'function');
await preconditions.add(req);
}
await validateAddToken(req);
const uuid = await ApiKey.make(req.body);
res.status(201).json({sid: uuid, token: req.body.token});
} catch (err) {

View File

@@ -1,7 +1,7 @@
const router = require('express').Router();
const sysError = require('../error');
const {DbErrorBadRequest} = require('../../utils/errors');
const {getHomerApiKey, getHomerSipTrace, getHomerPcap} = require('../../utils/homer-utils');
const parseAccountSid = (url) => {
const arr = /Accounts\/([^\/]*)/.exec(url);
if (arr) return arr[1];
@@ -34,4 +34,42 @@ router.get('/', async(req, res) => {
}
});
router.get('/:call_id', async(req, res) => {
const {logger} = req.app.locals;
try {
const token = await getHomerApiKey(logger);
if (!token) return res.sendStatus(400, {msg: 'Failed to get Homer API token; check server config'});
const obj = await getHomerSipTrace(logger, token, req.params.call_id);
if (!obj) {
logger.info(`/RecentCalls: unable to get sip traces from Homer for ${req.params.call_id}`);
return res.sendStatus(404);
}
res.status(200).json(obj);
} catch (err) {
logger.error({err}, '/RecentCalls error retrieving sip traces from homer');
res.sendStatus(err.statusCode || 500);
}
});
router.get('/:call_id/pcap', async(req, res) => {
const {logger} = req.app.locals;
try {
const token = await getHomerApiKey(logger);
if (!token) return res.sendStatus(400, {msg: 'getHomerApiKey: Failed to get Homer API token; check server config'});
const stream = await getHomerPcap(logger, token, [req.params.call_id]);
if (!stream) {
logger.info(`getHomerApiKey: unable to get sip traces from Homer for ${req.params.call_id}`);
return res.sendStatus(404);
}
res.set({
'Content-Type': 'application/octet-stream',
'Content-Disposition': `attachment; filename=callid-${req.params.call_id}.pcap`
});
stream.pipe(res);
} catch (err) {
logger.error({err}, 'getHomerApiKey error retrieving sip traces from homer');
res.sendStatus(err.statusCode || 500);
}
});
module.exports = router;

View File

@@ -20,6 +20,13 @@ values (?, ?, ?, ?, ?, 0, 'local', ?)`;
const insertAccountSql = `INSERT into accounts
(account_sid, service_provider_sid, name, is_active, webhook_secret, trial_end_date)
values (?, ?, ?, ?, ?, CURDATE() + INTERVAL 21 DAY)`;
const insertWebookSql = `INSERT INTO webhooks (webhook_sid, url, method)
VALUES (?, ?, ?)`;
const insertApplicationSql = `INSERT INTO applications
(application_sid, account_sid, name, call_hook_sid, call_status_hook_sid,
speech_synthesis_vendor, speech_synthesis_language, speech_synthesis_voice,
speech_recognizer_vendor, speech_recognizer_language)
VALUES (?,?,?,?,?,?,?,?,?,?)`;
const queryRootDomainSql = `SELECT root_domain
FROM service_providers
WHERE service_providers.service_provider_sid = ?`;
@@ -149,7 +156,7 @@ router.post('/', async(req, res) => {
const user = await doGoogleAuth(logger, req.body);
logger.info({user}, 'retrieved user details from google');
Object.assign(userProfile, {
name: user.name,
name: user.name || user.email,
email: user.email,
email_validated: user.verified_email,
picture: user.picture,
@@ -281,6 +288,22 @@ router.post('/', async(req, res) => {
userProfile.provider_userid);
}
/* add hello-world and dial-time as starter applications */
const callStatusSid = uuid();
const helloWordSid = uuid();
const dialTimeSid = uuid();
/* 3 webhooks */
await promisePool.execute(insertWebookSql, [callStatusSid, 'https://public-apps.jambonz.us/call-status', 'POST']);
await promisePool.execute(insertWebookSql, [helloWordSid, 'https://public-apps.jambonz.us/hello-world', 'POST']);
await promisePool.execute(insertWebookSql, [dialTimeSid, 'https://public-apps.jambonz.us/dial-time', 'POST']);
/* 2 applications */
await promisePool.execute(insertApplicationSql, [uuid(), userProfile.account_sid, 'hello world',
helloWordSid, callStatusSid, 'google', 'en-US', 'en-US-Wavenet-C', 'google', 'en-US']);
await promisePool.execute(insertApplicationSql, [uuid(), userProfile.account_sid, 'dial time clock',
dialTimeSid, callStatusSid, 'google', 'en-US', 'en-US-Wavenet-C', 'google', 'en-US']);
Object.assign(userProfile, {
pristine: true,
is_active: req.body.provider !== 'local',

View File

@@ -1,4 +1,5 @@
const router = require('express').Router();
const {promisePool} = require('../../db');
const {DbErrorUnprocessableRequest} = require('../../utils/errors');
const Webhook = require('../../models/webhook');
const ServiceProvider = require('../../models/service-provider');
@@ -12,11 +13,29 @@ const decorate = require('./decorate');
const preconditions = {
'delete': noActiveAccounts
};
const sqlDeleteSipGateways = `DELETE from sip_gateways
WHERE voip_carrier_sid IN (
SELECT voip_carrier_sid
FROM voip_carriers
WHERE service_provider_sid = ?
)`;
const sqlDeleteSmppGateways = `DELETE from smpp_gateways
WHERE voip_carrier_sid IN (
SELECT voip_carrier_sid
FROM voip_carriers
WHERE service_provider_sid = ?
)`;
/* can not delete a service provider if it has any active accounts */
async function noActiveAccounts(req, sid) {
const activeAccounts = await ServiceProvider.getForeignKeyReferences('accounts.service_provider_sid', sid);
if (activeAccounts > 0) throw new DbErrorUnprocessableRequest('cannot delete service provider with active accounts');
/* ok we can delete -- no active accounts. remove carriers and speech credentials */
await promisePool.execute('DELETE from speech_credentials WHERE service_provider_sid = ?', [sid]);
await promisePool.query(sqlDeleteSipGateways, [sid]);
await promisePool.query(sqlDeleteSmppGateways, [sid]);
await promisePool.query('DELETE from voip_carriers WHERE service_provider_sid = ?', [sid]);
}
decorate(router, ServiceProvider, ['delete'], preconditions);

View File

@@ -14,7 +14,7 @@ const {
router.post('/', async(req, res) => {
const logger = req.app.locals.logger;
const {use_for_stt, use_for_tts, vendor, service_key, access_key_id, secret_access_key, aws_region} = req.body;
const {account_sid} = req.user;
const account_sid = req.user.account_sid || req.body.account_sid;
let service_provider_sid;
if (!account_sid) {
if (!req.user.hasServiceProviderAuth) {

View File

@@ -74,12 +74,13 @@ const createTestCdrs = async(writeCdrs, account_sid) => {
for (let i = 0 ; i < points; i++) {
const attempted_at = new Date(start.getTime() + (i * increment));
const failed = 0 === i % 5;
const sip_callid = `685cd008-0a66-4974-b37a-bdd6d9a3c4a-${i % 2}`;
data.push({
call_sid: 'b6f48929-8e86-4d62-ae3b-64fb574d91f6',
from: '15083084809',
to: '18882349999',
answered: !failed,
sip_callid: '685cd008-0a66-4974-b37a-bdd6d9a3c4aa@192.168.1.100',
sip_callid,
sip_status: 200,
duration: failed ? 0 : 45,
attempted_at: attempted_at.getTime(),
@@ -167,6 +168,62 @@ const hasServiceProviderPermissions = (req, res, next) => {
});
};
const checkLimits = async(req, res, next) => {
const logger = req.app.locals.logger;
if (process.env.APPLY_JAMBONZ_DB_LIMITS && req.user.hasScope('account')) {
const account_sid = req.user.account_sid;
const url = req.originalUrl;
let sql;
let limit;
if (/Applications/.test(url)) {
limit = 50;
sql = 'SELECT count(*) as count from applications where account_sid = ?';
}
else if (/VoipCarriers/.test(url)) {
limit = 10;
sql = 'SELECT count(*) as count from voip_carriers where account_sid = ?';
}
else if (/SipGateways/.test(url)) {
limit = 150;
sql = `SELECT count(*) as count
from sip_gateways
where voip_carrier_sid IN (
SELECT voip_carrier_sid from voip_carriers
where account_sid = ?
)`;
}
else if (/PhoneNumbers/.test(url)) {
limit = 200;
sql = 'SELECT count(*) as count from phone_numbers where account_sid = ?';
}
else if (/SpeechCredentials/.test(url)) {
limit = 10;
sql = 'SELECT count(*) as count from speech_credentials where account_sid = ?';
}
else if (/ApiKeys/.test(url)) {
limit = 10;
sql = 'SELECT count(*) as count from api_keys where account_sid = ?';
}
if (sql) {
try {
const [r] = await promisePool.execute(sql, [account_sid]);
if (r[0].count >= limit) {
res.status(422).json({
status: 'fail',
message: `exceeded limits - you have created ${r.count} instances of this resource`
});
return;
}
} catch (err) {
logger.error({err}, 'Error checking limits');
}
}
}
next();
};
module.exports = {
setupFreeTrial,
createTestCdrs,
@@ -174,5 +231,6 @@ module.exports = {
parseAccountSid,
parseServiceProviderSid,
hasAccountPermissions,
hasServiceProviderPermissions
hasServiceProviderPermissions,
checkLimits
};

View File

@@ -5,9 +5,17 @@ const path = require('path');
const swaggerDocument = YAML.load(path.resolve(__dirname, '../swagger/swagger.yaml'));
const api = require('./api');
const stripe = require('./stripe');
const {checkLimits} = require('./api/utils');
const routes = express.Router();
routes.post([
'/v1/Applications',
'/v1/VoipCarriers',
'/v1/SipGateways',
'/v1/PhoneNumbers',
'/v1/Accounts'
], checkLimits);
routes.use('/v1', api);
routes.use('/stripe', stripe);
routes.use('/swagger', swaggerUi.serve);

View File

@@ -1958,7 +1958,7 @@ paths:
put:
summary: update tenant
operationId: updateAccount
operationId: putTenant
requestBody:
content:
application/json:
@@ -2004,6 +2004,9 @@ paths:
registration_hook:
$ref: '#/components/schemas/Webhook'
description: authentication webhook for registration
queue_event_hook:
$ref: '#/components/schemas/Webhook'
description: webhook called when members join or leave a queue
service_provider_sid:
type: string
format: uuid
@@ -2635,6 +2638,56 @@ paths:
- duration
404:
description: account not found
/Accounts/{AccountSid}/RecentCalls/{CallId}:
parameters:
- name: AccountSid
in: path
required: true
schema:
type: string
format: uuid
- name: CallId
in: path
required: true
schema:
type: string
get:
summary: retrieve sip trace detail for a call
operationId: getRecentCallTrace
responses:
200:
description: retrieve sip trace data
content:
application/json:
schema:
type: object
404:
description: account or call not found
/Accounts/{AccountSid}/RecentCalls/{CallId}/pcap:
parameters:
- name: AccountSid
in: path
required: true
schema:
type: string
format: uuid
- name: CallId
in: path
required: true
schema:
type: string
get:
summary: retrieve pcap for a call
operationId: getRecentCallTrace
responses:
200:
description: retrieve sip trace data
content:
application/octet-stream:
schema:
type: object
404:
description: account or call not found
/Accounts/{AccountSid}/Alerts:
parameters:
- name: AccountSid
@@ -3632,6 +3685,8 @@ components:
type: string
registration_hook_sid:
type: string
queue_event_hook_sid:
type: string
device_calling_application_sid:
type: string
is_active:

93
lib/utils/homer-utils.js Normal file
View File

@@ -0,0 +1,93 @@
const debug = require('debug')('jambonz:api-server');
const bent = require('bent');
const basicAuth = (apiKey) => {
const header = `Bearer ${apiKey}`;
return {Authorization: header};
};
const postJSON = bent(process.env.HOMER_BASE_URL || 'http://127.0.0.1', 'POST', 'json', 200, 201);
const postPcap = bent(process.env.HOMER_BASE_URL || 'http://127.0.0.1', 'POST', 200, {
'Content-Type': 'application/json',
'Accept': 'application/json, text/plain, */*',
});
const SEVEN_DAYS_IN_MS = (1000 * 3600 * 24 * 7);
const getHomerApiKey = async(logger) => {
if (!process.env.HOMER_BASE_URL || !process.env.HOMER_USERNAME || !process.env.HOMER_PASSWORD) {
logger.debug('getHomerApiKey: Homer integration not installed');
}
try {
const obj = await postJSON('/api/v3/auth', {
username: process.env.HOMER_USERNAME,
password: process.env.HOMER_PASSWORD
});
debug(obj);
logger.debug({obj}, `getHomerApiKey for user ${process.env.HOMER_USERNAME}`);
return obj.token;
} catch (err) {
debug(err);
logger.info({err}, `getHomerApiKey: Error retrieving apikey for user ${process.env.HOMER_USERNAME}`);
}
};
const getHomerSipTrace = async(logger, apiKey, callId) => {
if (!process.env.HOMER_BASE_URL || !process.env.HOMER_USERNAME || !process.env.HOMER_PASSWORD) {
logger.debug('getHomerSipTrace: Homer integration not installed');
}
try {
const now = Date.now();
const obj = await postJSON('/api/v3/call/transaction', {
param: {
transaction: {
call: true
},
search: {
'1_call': {
callid: [callId]
}
},
},
timestamp: {
from: now - SEVEN_DAYS_IN_MS,
to: now
}
}, basicAuth(apiKey));
return obj;
} catch (err) {
logger.info({err}, `getHomerSipTrace: Error retrieving messages for callid ${callId}`);
}
};
const getHomerPcap = async(logger, apiKey, callIds) => {
if (!process.env.HOMER_BASE_URL || !process.env.HOMER_USERNAME || !process.env.HOMER_PASSWORD) {
logger.debug('getHomerPcap: Homer integration not installed');
}
try {
const now = Date.now();
const stream = await postPcap('/api/v3/export/call/messages/pcap', {
param: {
transaction: {
call: true
},
search: {
'1_call': {
callid: callIds
}
},
},
timestamp: {
from: now - SEVEN_DAYS_IN_MS,
to: now
}
}, basicAuth(apiKey));
return stream;
} catch (err) {
logger.info({err}, `getHomerPcap: Error retrieving messages for callid ${callIds}`);
}
};
module.exports = {
getHomerApiKey,
getHomerSipTrace,
getHomerPcap
};

View File

@@ -5,7 +5,7 @@
"main": "app.js",
"scripts": {
"start": "node app.js",
"test": "NODE_ENV=test JAMBONES_CURRENCY=USD JWT_SECRET=foobarbazzle JAMBONES_MYSQL_HOST=127.0.0.1 JAMBONES_MYSQL_PORT=3360 JAMBONES_MYSQL_USER=jambones_test JAMBONES_MYSQL_PASSWORD=jambones_test JAMBONES_MYSQL_DATABASE=jambones_test JAMBONES_REDIS_HOST=localhost JAMBONES_REDIS_PORT=16379 JAMBONES_TIME_SERIES_HOST=127.0.0.1 JAMBONES_LOGLEVEL=error JAMBONES_CREATE_CALL_URL=http://localhost/v1/createCall node test/ ",
"test": "NODE_ENV=test APPLY_JAMBONZ_DB_LIMITS=1 JWT_SECRET=foobarbazzle JAMBONES_MYSQL_HOST=127.0.0.1 JAMBONES_MYSQL_PORT=3360 JAMBONES_MYSQL_USER=jambones_test JAMBONES_MYSQL_PASSWORD=jambones_test JAMBONES_MYSQL_DATABASE=jambones_test JAMBONES_REDIS_HOST=localhost JAMBONES_REDIS_PORT=16379 JAMBONES_TIME_SERIES_HOST=127.0.0.1 JAMBONES_LOGLEVEL=error JAMBONES_CREATE_CALL_URL=http://localhost/v1/createCall node test/ ",
"integration-test": "NODE_ENV=test JAMBONES_TIME_SERIES_HOST=127.0.0.1 AWS_REGION='us-east-1' JAMBONES_CURRENCY=USD JWT_SECRET=foobarbazzle JAMBONES_MYSQL_HOST=127.0.0.1 JAMBONES_MYSQL_PORT=3360 JAMBONES_MYSQL_USER=jambones_test JAMBONES_MYSQL_PASSWORD=jambones_test JAMBONES_MYSQL_DATABASE=jambones_test JAMBONES_REDIS_HOST=localhost JAMBONES_REDIS_PORT=16379 JAMBONES_LOGLEVEL=debug JAMBONES_CREATE_CALL_URL=http://localhost/v1/createCall node test/serve-integration.js",
"coverage": "./node_modules/.bin/nyc --reporter html --report-dir ./coverage npm run test",
"jslint": "eslint app.js lib"
@@ -28,17 +28,13 @@
"debug": "^4.3.1",
"express": "^4.17.1",
"form-data": "^2.3.3",
"form-urlencoded": "^4.2.1",
"google-libphonenumber": "^3.2.15",
"form-urlencoded": "^6.0.4",
"jsonwebtoken": "^8.5.1",
"mailgun.js": "^3.3.0",
"mysql2": "^2.2.5",
"passport": "^0.4.1",
"passport-http-bearer": "^1.0.1",
"pino": "^5.17.0",
"qs": "^6.7.0",
"request": "^2.88.2",
"request-debug": "^0.2.0",
"short-uuid": "^4.1.0",
"stripe": "^8.138.0",
"swagger-ui-express": "^4.1.6",
@@ -49,6 +45,7 @@
"eslint": "^7.17.0",
"eslint-plugin-promise": "^4.2.1",
"nyc": "^15.1.0",
"request": "^2.88.2",
"request-promise-native": "^1.0.9",
"tape": "^5.2.2"
}

View File

@@ -154,6 +154,10 @@ test('account tests', async(t) => {
registration_hook: {
url: 'http://example.com/reg2',
method: 'get'
},
queue_event_hook: {
url: 'http://example.com/q',
method: 'post'
}
}
});

View File

@@ -1,5 +1,13 @@
version: '3'
networks:
jambonz-api:
driver: bridge
ipam:
config:
- subnet: 172.58.0.0/16
services:
mysql:
image: mysql:5.7
@@ -10,7 +18,11 @@ services:
healthcheck:
test: ["CMD", "mysqladmin" ,"ping", "-h", "127.0.0.1", "--protocol", "tcp"]
timeout: 5s
retries: 10
retries: 10
networks:
jambonz-api:
ipv4_address: 172.58.0.2
redis:
image: redis:5-alpine
ports:
@@ -18,8 +30,101 @@ services:
depends_on:
mysql:
condition: service_healthy
networks:
jambonz-api:
ipv4_address: 172.58.0.3
influxdb:
image: influxdb:1.8-alpine
ports:
- "8086:8086"
networks:
jambonz-api:
ipv4_address: 172.58.0.4
db:
image: postgres:11-alpine
restart: always
environment:
POSTGRES_PASSWORD: homerSeven
POSTGRES_USER: root
expose:
- 5432
restart: unless-stopped
volumes:
- ./postgresql/init-user-db.sh:/docker-entrypoint-initdb.d/init-user-db.sh
- ./postgres-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "psql -h 'localhost' -U 'root' -c '\\l'"]
interval: 3s
timeout: 3s
retries: 60
networks:
jambonz-api:
ipv4_address: 172.58.0.5
heplify-server:
image: sipcapture/heplify-server
container_name: heplify-server
ports:
- "9069:9060"
- "9060:9060/udp"
- "9061:9061/tcp"
command:
- './heplify-server'
environment:
- "HEPLIFYSERVER_HEPADDR=0.0.0.0:9060"
- "HEPLIFYSERVER_HEPTCPADDR=0.0.0.0:9061"
- "HEPLIFYSERVER_DBDRIVER=postgres"
- "HEPLIFYSERVER_DBSHEMA=homer7"
- "HEPLIFYSERVER_DBADDR=db:5432"
- "HEPLIFYSERVER_DBUSER=root"
- "HEPLIFYSERVER_DBPASS=homerSeven"
- "HEPLIFYSERVER_DBDATATABLE=homer_data"
- "HEPLIFYSERVER_DBROTATE=true"
- "HEPLIFYSERVER_LOGLVL=debug"
- "HEPLIFYSERVER_LOGSTD=true"
- "HEPLIFYSERVER_DBDROPDAYS=7"
- "HEPLIFYSERVER_ALEGIDS=X-CID"
restart: unless-stopped
depends_on:
db:
condition: service_healthy
networks:
jambonz-api:
ipv4_address: 172.58.0.6
homer-webapp:
container_name: homer-webapp
image: sipcapture/webapp
environment:
- "DB_HOST=db"
- "DB_USER=root"
- "DB_PASS=homerSeven"
ports:
- "9090:80"
expose:
- 80
restart: unless-stopped
volumes:
- ./bootstrap:/app/bootstrap
depends_on:
db:
condition: service_healthy
networks:
jambonz-api:
ipv4_address: 172.58.0.7
drachtio:
container_name: drachtio
image: drachtio/drachtio-server:latest
command: drachtio --contact "sip:*;transport=udp" --loglevel debug --sofia-loglevel 9 --homer 172.58.0.6:9060 --homer-id 10
networks:
jambonz-api:
ipv4_address: 172.58.0.8
depends_on:
db:
condition: service_healthy

45
test/homer.js Normal file
View File

@@ -0,0 +1,45 @@
const test = require('tape') ;
const noopLogger = {debug: () => {}, info: () => {}, error: () => {}};
const fs = require('fs');
test('homer tests', async(t, done) => {
//const {getHomerApiKey, getHomerSipTrace, getHomerPcap} = require('../lib/utils/homer-utils');
if (process.env.HOMER_BASE_URL && process.env.HOMER_USERNAME && process.env.HOMER_PASSWORD) {
try {
/* get a token */
/*
let token = await getHomerApiKey(noopLogger);
console.log(token);
t.ok(token, 'successfully created an api key for homer');
const result = await getHomerSipTrace(noopLogger, token, '224f0f24-69aa-123a-eaa6-0ea24be4d211');
console.log(`got trace: ${JSON.stringify(result)}`);
var writeStream = fs.createWriteStream('./call.pcap');
const stream = await getHomerPcap(noopLogger, token, ['224f0f24-69aa-123a-eaa6-0ea24be4d211']);
stream.pipe(writeStream);
stream.on('end', () => {
console.log('finished writing');
done();
});
*/
let result = await request.get('/RecentCalls/224f0f24-69aa-123a-eaa6-0ea24be4d211', {
resolveWithFullResponse: true,
auth: authAdmin,
json: true,
body: {
service_provider_sid,
account_sid,
tenant_fqdn: 'foo.bar.baz'
}
});
t.ok(result.statusCode === 201, 'successfully added ms teams tenant');
}
catch (err) {
console.error(err);
t.end(err);
}
}
});

View File

@@ -13,4 +13,5 @@ require('./ms-teams');
require('./speech-credentials');
require('./recent-calls');
require('./webapp_tests');
//require('./homer');
require('./docker_stop');

View File

@@ -1,7 +1,6 @@
const bent = require('bent');
const getJSON = bent('GET', 200);
const request = require('request');
require('request-debug')(request);
const test = async() => {
request.get('https://api.github.com/user', {

View File

@@ -0,0 +1,6 @@
#!/bin/bash
set -e
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL
CREATE DATABASE homer_config;
EOSQL

View File

@@ -66,6 +66,22 @@ test('recent calls tests', async(t) => {
json: true,
});
/* pull sip traces and pcap from homer */
/*
result = await request.get(`/Accounts/${account_sid}/RecentCalls/224f0f24-69aa-123a-eaa6-0ea24be4d211`, {
auth: authUser,
json: true
});
console.log(result);
const writeStream = fs.createWriteStream('./call.pcap');
const ret = await request.get(`/Accounts/${account_sid}/RecentCalls/224f0f24-69aa-123a-eaa6-0ea24be4d211/pcap`, {
auth: authUser,
resolveWithFullResponse: true
});
writeStream.write(ret.body);
*/
await deleteObjectBySid(request, '/Accounts', account_sid);
await deleteObjectBySid(request, '/ServiceProviders', service_provider_sid);

68
test/scenarios/uac.xml Normal file
View File

@@ -0,0 +1,68 @@
<?xml version="1.0" encoding="ISO-8859-1" ?>
<!DOCTYPE scenario SYSTEM "sipp.dtd">
<scenario name="UAC with media">
<send retrans="500">
<![CDATA[
INVITE sip:+15083871234@echo.sip.jambonz.org SIP/2.0
Via: SIP/2.0/[transport] [local_ip]:[local_port];branch=[branch]
From: sipp <sip:sipp@[local_ip]:[local_port]>;tag=[pid]SIPpTag09[call_number]
To: <sip:15083871234@echo.sip.jambonz.org>
Call-ID: 685cd008-0a66-4974-b37a-bdd6d9a3c4a-0
CSeq: 1 INVITE
Contact: sip:sipp@[local_ip]:[local_port]
Max-Forwards: 70
Content-Type: application/sdp
Content-Length: [len]
v=0
o=user1 53655765 2353687637 IN IP[local_ip_type] [local_ip]
s=-
c=IN IP[local_ip_type] [local_ip]
t=0 0
m=audio [auto_media_port] RTP/AVP 8 101
a=rtpmap:8 PCMA/8000
a=rtpmap:101 telephone-event/8000
a=fmtp:101 0-11,16
]]>
</send>
<recv response="100" optional="true">
</recv>
<!-- By adding rrs="true" (Record Route Sets), the route sets -->
<!-- are saved and used for following messages sent. Useful to test -->
<!-- against stateful SIP proxies/B2BUAs. -->
<recv response="503" rtd="true" crlf="true">
</recv>
<!-- Packet lost can be simulated in any send/recv message by -->
<!-- by adding the 'lost = "10"'. Value can be [1-100] percent. -->
<send>
<![CDATA[
ACK sip:15083871234@echo.sip.jambonz.org SIP/2.0
[last_Via]
From: sipp <sip:sipp@[local_ip]:[local_port]>;tag=[pid]SIPpTag09[call_number]
To: <sip:15083871234@echo.sip.jambonz.org>[peer_tag_param]
Call-ID: 685cd008-0a66-4974-b37a-bdd6d9a3c4a-0
CSeq: 1 ACK
Max-Forwards: 70
Subject: uac-pcap-carrier-max-call-limit
Content-Length: 0
]]>
</send>
<!-- definition of the response time repartition table (unit is ms) -->
<ResponseTimeRepartition value="10, 20, 30, 40, 50, 100, 150, 200"/>
<!-- definition of the call length repartition table (unit is ms) -->
<CallLengthRepartition value="10, 50, 100, 500, 1000, 5000, 10000"/>
</scenario>

View File

@@ -1,4 +1,5 @@
const exec = require('child_process').exec ;
const { sippUac } = require('./sipp')('test_jambonz-api');
let stopping = false;
process.on('SIGINT', async() => {
@@ -66,6 +67,14 @@ const resetAdminPassword = () => {
});
};
const generateSipTrace = async() => {
try {
await sippUac('uac.xml', '172.58.0.30');
} catch (err) {
console.log(err);
}
};
const stopDocker = () => {
return new Promise((resolve, reject) => {
console.log('stopping docker network..')
@@ -81,6 +90,7 @@ startDocker()
.then(createSchema)
.then(seedDb)
.then(resetAdminPassword)
.then(generateSipTrace)
.then(() => {
console.log('ready for testing!');
require('..');

68
test/sipp.js Normal file
View File

@@ -0,0 +1,68 @@
const { spawn } = require('child_process');
const debug = require('debug')('jambonz:ci');
let network;
const obj = {};
let output = '';
let idx = 1;
function clearOutput() {
output = '';
}
function addOutput(str) {
for (let i = 0; i < str.length; i++) {
if (str.charCodeAt(i) < 128) output += str.charAt(i);
}
}
module.exports = (networkName) => {
network = networkName ;
return obj;
};
obj.output = () => {
return output;
};
obj.sippUac = (file, bindAddress) => {
const cmd = 'docker';
const args = [
'run', '--rm', '--net', `${network}`,
'-v', `${__dirname}/scenarios:/tmp/scenarios`,
'drachtio/sipp', 'sipp', '-sf', `/tmp/scenarios/${file}`,
'-m', '1',
'-sleep', '250ms',
'-nostdin',
'-cid_str', `%u-%p@%s-${idx++}`,
'drachtio'
];
if (bindAddress) args.splice(4, 0, '--ip', bindAddress);
//console.log(args.join(' '));
clearOutput();
return new Promise((resolve, reject) => {
const child_process = spawn(cmd, args, {stdio: ['inherit', 'pipe', 'pipe']});
child_process.on('exit', (code, signal) => {
if (code === 0) {
return resolve();
}
console.log(`sipp exited with non-zero code ${code} signal ${signal}`);
reject(code);
});
child_process.on('error', (error) => {
console.log(`error spawing child process for docker: ${args}`);
});
child_process.stdout.on('data', (data) => {
debug(`stderr: ${data}`);
addOutput(data.toString());
});
child_process.stderr.on('data', (data) => {
debug(`stderr: ${data}`);
addOutput(data.toString());
});
});
};