diff --git a/docs/domains-json.md b/docs/domains-json.md index 11cdafab8..e502fb144 100644 --- a/docs/domains-json.md +++ b/docs/domains-json.md @@ -21,7 +21,7 @@ In the owner object, the fields `username` and `email` are required. You can how "owner": { "username": "github-username", "email": "any@email" - }, + } } ``` @@ -32,7 +32,7 @@ If you don't wish to share your email address here, please share your twitter or "username": "github-username", "email": "", "twitter": "twitter-handle" - }, + } } ``` @@ -46,14 +46,14 @@ This is a link to your website repository or your github account. This is purely ### record (required) -This is where you specify how you want to link to your server/webpage. +This is where you specify the DNS records you wish to use. -Currently, only `CNAME`, `A`, `URL` record types are supported. +The supported record types are: `CNAME`, `A`, `URL`, `MX` and `TXT` Here's a few different use cases for the given record types - * **CNAME** -CNAME must be a host name (Eg - `something.tld`) +CNAME must be a host name (Eg - `something.tld`). CNAME cannot be used in conjunction with any other record types. ```json { "record": { @@ -86,3 +86,25 @@ A record must be a list of ips } ``` +* **MX** +MX must be a list of host names +```json +{ + "record": { + "MX": [ + "mx1.improvmx.com", + "mx2.improvmx.com" + ] + } +} +``` + +* **TXT** +TXT can be any string value +```json +{ + "record": { + "TXT": "hello world" + } +} +``` diff --git a/domains/akshay-n.json b/domains/akshay-n.json new file mode 100644 index 000000000..54a9a6909 --- /dev/null +++ b/domains/akshay-n.json @@ -0,0 +1,15 @@ +{ + "description": "Akshay's email alias", + "repo": "https://github.com/phenax/phenax.github.io", + "owner": { + "username": "phenax", + "email": "phenax5@gmail.com" + }, + "record": { + "TXT": "v=spf1 include:spf.improvmx.com ~all", + "MX": [ + "mx1.improvmx.com", + "mx2.improvmx.com" + ] + } +} diff --git a/domains/akshay.json b/domains/akshay.json index 4523d9f33..ff8803950 100644 --- a/domains/akshay.json +++ b/domains/akshay.json @@ -8,4 +8,4 @@ "record": { "CNAME": "phenax.github.io" } -} \ No newline at end of file +} diff --git a/domains/dipan.json b/domains/dipan.json index 617f3f79d..1e7956dd9 100644 --- a/domains/dipan.json +++ b/domains/dipan.json @@ -6,6 +6,6 @@ "email": "dipanroy@mindwebs.org" }, "record": { - "A": "115.187.62.14" + "A": ["115.187.62.14"] } -} \ No newline at end of file +} diff --git a/scripts/register-domains.js b/scripts/register-domains.js index 1cefbda7f..3f1a5a894 100644 --- a/scripts/register-domains.js +++ b/scripts/register-domains.js @@ -4,24 +4,25 @@ const { domainService: dc } = require('../utils/domain-service'); const { getDomains: gd } = require('../utils/get-domain'); // Allow TXT records while publishing (for pcl validation) -const getRecords = R.compose(R.toPairs, R.pick(VALID_RECORD_TYPES.concat(['TXT']))); +const getRecords = R.compose(R.toPairs, R.pick(VALID_RECORD_TYPES)); const toHostList = R.chain(data => { const rs = getRecords(data.record); return R.chain(([recordType, urls]) => - (Array.isArray(urls) ? urls : [urls]).map(url => ({ + (Array.isArray(urls) ? urls : [urls]).map((url, index) => ({ name: data.name, type: recordType, address: (recordType === 'CNAME' ? `${url}`.toLowerCase() : `${url}`).replace(/\/$/g, ''), ttl: TTL, + ...(recordType === 'MX' ? { priority: index + 20 } : {}) })) - , rs); + , rs); }); -const registerDomains = async ({ domainService, getDomains, log = () => {} }) => { +const registerDomains = async ({ domainService, getDomains, log = () => { } }) => { const domains = await getDomains().then(toHostList); - + if (domains.length === 0) return Promise.reject(new Error('Nothing to register')); diff --git a/tests/domain-service.test.js b/tests/domain-service.test.js index d51c0916f..166712596 100644 --- a/tests/domain-service.test.js +++ b/tests/domain-service.test.js @@ -1,8 +1,8 @@ const R = require('ramda'); const { getDomainService, diffRecords } = require('../utils/domain-service'); -const {DOMAIN_DOMAIN} = require('../utils/constants'); +const { DOMAIN_DOMAIN } = require('../utils/constants'); -const getCpanel = ({ zone, addZone, removeZone, redir, addRedir, removeRedir } = {}) => ({ +const getCpanel = ({ zone, addZone, removeZone, redir, addRedir, removeRedir, addEmail, removeEmail } = {}) => ({ zone: { fetch: (_) => zone(), add: (rec) => addZone(rec), @@ -13,6 +13,10 @@ const getCpanel = ({ zone, addZone, removeZone, redir, addRedir, removeRedir } = add: (rec) => addRedir(rec), remove: (rec) => removeRedir(rec), }, + email: { + add: (rec) => addEmail(rec), + remove: (rec) => removeEmail(rec), + }, }); describe('diffRecords', () => { @@ -114,23 +118,33 @@ describe('Domain service', () => { const removeZone = jest.fn(async () => ({})); const addRedir = jest.fn(async () => ({})); const removeRedir = jest.fn(async () => ({})); + const addEmail = jest.fn(async () => ({})); + const removeEmail = jest.fn(async () => ({})); - const mockDS = ({ zones, redirections }) => getDomainService({ cpanel: getCpanel({ - zone: async () => zones, - redir: async () => redirections, - addZone, - addRedir, - removeZone, - removeRedir, - }) }); + const mockDS = ({ zones, redirections }) => getDomainService({ + cpanel: getCpanel({ + zone: async () => zones, + redir: async () => redirections, + addZone, + addEmail, + addRedir, + removeZone, + removeRedir, + removeEmail, + }) + }); - const getRecordCalls = recfn => recfn.mock.calls.map(R.head).map(R.pick(['name', 'type', 'address', 'redirect', 'domain', 'line'])); + const getRecordCalls = recfn => recfn.mock.calls + .map(R.head) + .map(R.pick(['name', 'type', 'address', 'redirect', 'domain', 'line', 'priority', 'exchanger'])); beforeEach(() => { addZone.mockClear(); removeZone.mockClear(); addRedir.mockClear(); removeRedir.mockClear(); + addEmail.mockClear(); + removeEmail.mockClear(); }); describe('getHosts', () => { @@ -187,13 +201,21 @@ describe('Domain service', () => { { name: 'a', type: 'CNAME', address: 'boo' }, { name: 'b', type: 'CNAME', address: 'goo' }, { name: 'c', type: 'A', address: '12.131321.213' }, + { name: 'c', type: 'MX', address: 'foobar.com', priority: 2 }, ]); expect(addZone).toBeCalledTimes(1); expect(getRecordCalls(addZone)).toEqual([ { name: 'c', type: 'A', address: '12.131321.213' }, ]); + + expect(addEmail).toBeCalledTimes(1); + expect(getRecordCalls(addEmail)).toEqual([ + { domain: 'c.is-a.dev', exchanger: 'foobar.com', priority: 2 }, + ]); + expect(removeZone).toBeCalledTimes(0); + expect(removeEmail).toBeCalledTimes(0); }); it('should update matching host and set it', async () => { @@ -252,6 +274,9 @@ describe('Domain service', () => { { line: 2, name: 'b', type: 'A', address: '1' }, { line: 3, name: 'b', type: 'A', address: '2' }, { line: 4, name: 'c', type: 'CNAME', address: 'hello.com' }, + { line: 5, name: 'c', type: 'MX', address: 'mx1.hello.com', priority: 20 }, + { line: 6, name: 'c', type: 'MX', address: 'mx2.hello.com', priority: 21 }, + { line: 7, name: 'b', type: 'MX', address: 'foo.bar', priority: 20 }, ]; const redirections = [ { domain: `b.${DOMAIN_DOMAIN}`, destination: 'https://foobar.com' }, @@ -271,6 +296,8 @@ describe('Domain service', () => { { name: 'd', type: 'CNAME', address: 'helo.com' }, { name: 'd', type: 'URL', address: 'https://hhh.com' }, { name: 'x', type: 'URL', address: 'https://example69.com' }, + { name: 'c', type: 'MX', address: 'mx2.hello.com', priority: 21 }, + { name: 'a', type: 'MX', address: 'example.com', priority: 20 }, ]); expect(addZone).toBeCalledTimes(3); @@ -283,6 +310,17 @@ describe('Domain service', () => { expect(getRecordCalls(removeZone)).toEqual([ { line: 1 }, ]); + + expect(addEmail).toBeCalledTimes(1); + expect(getRecordCalls(addEmail)).toEqual([ + { domain: 'a.is-a.dev', exchanger: 'example.com', priority: 20 }, + ]); + expect(removeEmail).toBeCalledTimes(2); + expect(getRecordCalls(removeEmail)).toEqual([ + { domain: 'c.is-a.dev', exchanger: 'mx1.hello.com', priority: 20 }, + { domain: 'b.is-a.dev', exchanger: 'foo.bar', priority: 20 }, + ]); + expect(addRedir).toBeCalledTimes(3); expect(getRecordCalls(addRedir)).toEqual([ { domain: `b.${DOMAIN_DOMAIN}`, type: 'permanent', redirect: 'https://wowow.com' }, diff --git a/tests/register.test.js b/tests/register.test.js index 68c15e5be..88eb4694e 100644 --- a/tests/register.test.js +++ b/tests/register.test.js @@ -14,6 +14,10 @@ const getCpanel = ({ zone, addZone, removeZone, redir, addRedir, removeRedir } = add: (rec) => addRedir(rec), remove: (rec) => removeRedir(rec), }, + email: { + add: (rec) => addEmail(rec), + remove: (rec) => removeEmail(rec), + }, }); describe('toHostList', () => { @@ -22,6 +26,7 @@ describe('toHostList', () => { { name: 'akshay', record: { CNAME: 'phenax.github.io' } }, { name: 'foobar', record: { CNAME: 'v.io' } }, { name: 'xx', record: { A: ['1.2.3.4', '5.6.3.2', '1.2.31.1'] } }, + { name: 'xx', record: { CNAME: 'foobar.com', MX: ['as.com', 'f.com'] } }, ]); expect(res).toEqual([ @@ -30,6 +35,9 @@ describe('toHostList', () => { { name: 'xx', type: 'A', address: '1.2.3.4', ttl: TTL }, { name: 'xx', type: 'A', address: '5.6.3.2', ttl: TTL }, { name: 'xx', type: 'A', address: '1.2.31.1', ttl: TTL }, + { name: 'xx', type: 'CNAME', address: 'foobar.com', ttl: TTL }, + { name: 'xx', type: 'MX', address: 'as.com', priority: 20, ttl: TTL }, + { name: 'xx', type: 'MX', address: 'f.com', priority: 21, ttl: TTL }, ]); }); }); @@ -39,21 +47,29 @@ describe('registerDomains', () => { const removeZone = jest.fn(async () => ({})); const addRedir = jest.fn(async () => ({})); const removeRedir = jest.fn(async () => ({})); + const addEmail = jest.fn(async () => ({})); + const removeEmail = jest.fn(async () => ({})); - const mockDS = ({ zones, redirections }) => getDomainService({ cpanel: getCpanel({ - zone: async () => zones, - redir: async () => redirections, - addZone, - addRedir, - removeZone, - removeRedir, - }) }); + const mockDS = ({ zones, redirections }) => getDomainService({ + cpanel: getCpanel({ + zone: async () => zones, + redir: async () => redirections, + addZone, + addEmail, + addRedir, + removeZone, + removeRedir, + removeEmail, + }) + }); beforeEach(() => { addZone.mockClear(); removeZone.mockClear(); addRedir.mockClear(); removeRedir.mockClear(); + addEmail.mockClear(); + removeEmail.mockClear(); }); it('should register the new set of hosts generated from domains list', async () => { diff --git a/tests/validations.test.js b/tests/validations.test.js index 2971b82a7..ceadd3ee9 100644 --- a/tests/validations.test.js +++ b/tests/validations.test.js @@ -1,4 +1,5 @@ -const { validateDomainData } = require('../utils/validations'); +const { validateDomainData, isValidDomain } = require('../utils/validations'); +const INVALID_NAMES = require('../utils/invalid-domains.json'); const defaultDomain = { name: 'aaa', @@ -13,6 +14,25 @@ const defaultDomain = { const getstroflen = len => Array(len).fill('a').join(''); +describe('isValidMX', () => { + it('should be valid mx record', () => { + const cases = [ + { mx: 'foobar.com', result: true }, + { mx: 'as.as', result: true }, + { mx: 'ASPMX.L.GOOGLE.COM', result: true }, + { mx: 'ALT4.ASPMX.L.GOOGLE.COM', result: true }, + { mx: 'hello', result: false }, + { mx: 'helalsds-asd5sjdsd.com', result: true }, + { mx: 'helalsds?asd5sjdsd.com', result: false }, + { mx: 'helalsds_asd5sjdsd.com', result: false }, + ]; + + cases.forEach(({ mx, result }) => { + expect(isValidDomain(mx)).toBe(result); + }); + }); +}); + describe('validateDomainData', () => { const invalidCases = [ {}, @@ -32,6 +52,10 @@ describe('validateDomainData', () => { { ...defaultDomain, record: { CNAME: 'http://foobar.com' } }, { ...defaultDomain, record: { CNAME: 'https://foobar.com' } }, { ...defaultDomain, record: { URL: 'foobar.com' } }, + { ...defaultDomain, record: { CNAME: 'foobar.com', A: ['11.22.22.33'] } }, + { ...defaultDomain, record: { CNAME: 'foobar.com', MX: ['ALT4.ASPMX.L.GOOGLE.COM'] } }, + ...INVALID_NAMES.map(name => ({ ...defaultDomain, name })).slice(0, 1), + { ...defaultDomain, record: { TXT: ['foobar wow nice!!!'] } }, ]; const validCases = [ @@ -44,9 +68,12 @@ describe('validateDomainData', () => { ...defaultDomain, description: getstroflen(99), }, - { ...defaultDomain, record: { CNAME: 'aa.sd', URL: '121,3213' } }, + { ...defaultDomain, record: { CNAME: 'aa.sd' } }, { ...defaultDomain, record: { URL: 'https://foobar.com' } }, { ...defaultDomain, record: { URL: 'http://foobar.com/foobar/' } }, + { ...defaultDomain, record: { MX: ['ALT4.ASPMX.L.GOOGLE.COM'] } }, + { ...defaultDomain, record: { TXT: 'foobar wow nice!!!' } }, + { ...defaultDomain, record: { A: ['1.1.1.1'], MX: ['mx1.example.com'] } }, ]; it('should return false for invalid data', () => { @@ -60,7 +87,7 @@ describe('validateDomainData', () => { it('should return true if the name is valid', () => { validCases.forEach(data => { const { valid, errors } = validateDomainData(data); - if (!valid) console.log(errors); + if (!valid) console.log(JSON.stringify(errors, null, 2)); expect(valid).toBe(true); expect(errors).toEqual([]); }); diff --git a/utils/constants.js b/utils/constants.js index f3ef2b6ac..8977f8013 100644 --- a/utils/constants.js +++ b/utils/constants.js @@ -21,12 +21,12 @@ const DOMAINS_PATH = require('path').resolve('domains'); module.exports = { ENV, IS_TEST, - VALID_RECORD_TYPES: ['CNAME', 'A', 'URL'], + VALID_RECORD_TYPES: ['CNAME', 'A', 'URL', 'MX', 'TXT'], DOMAIN_DOMAIN: DOMAIN_DOMAIN || 'booboo.xyz', DOMAIN_USER: IS_TEST ? 'testuser' : DOMAIN_USER, DOMAIN_API_KEY: IS_TEST ? 'testkey' : DOMAIN_API_KEY, DOMAIN_API_HOST: IS_TEST ? 'example.com' : DOMAIN_API_HOST, DOMAIN_API_PORT: IS_TEST ? 6969 : DOMAIN_API_PORT, DOMAINS_PATH, - TTL: 5*60*60, + TTL: 5 * 60 * 60, }; diff --git a/utils/domain-service.js b/utils/domain-service.js index 55ea3adb3..14112d484 100644 --- a/utils/domain-service.js +++ b/utils/domain-service.js @@ -12,22 +12,24 @@ const recordToRedirection = ({ name, address }) => ({ redirect_wildcard: 1, redirect_www: 1, }); -const recordToZone = ({ name, type, address, id }) => ({ +const recordToZone = ({ name, type, address, id, priority }) => ({ line: id, name, type, address, + ...(type === 'MX' ? { priority } : {}), ...(type === 'CNAME' ? { cname: address } : {}), ...(type === 'TXT' ? { txtdata: address } : {}), }); const cleanName = name => name === DOMAIN_DOMAIN ? '@' : `${name}`.replace(new RegExp(`\\.${DOMAIN_DOMAIN}\\.?$`), '').toLowerCase(); -const zoneToRecord = ({ name, type, cname, address, record, line: id }) => ({ +const zoneToRecord = ({ name, type, cname, address, priority, preference, exchange, record, line: id }) => ({ id, name: cleanName(name), type: `${type}`, - address: `${cname || address || record}`.replace(/\.$/g, '').toLowerCase(), + address: `${exchange || cname || address || record}`.replace(/\.$/g, '').toLowerCase(), + priority: priority || preference, }); const redirectionToRecord = ({ domain, destination }) => ({ id: domain, @@ -36,6 +38,12 @@ const redirectionToRecord = ({ domain, destination }) => ({ address: `${destination}`.replace(/\/$/g, ''), }); +const recordToEmailMx = ({ name, address, priority }) => ({ + domain: `${name}.is-a.dev`, + exchanger: address, + priority, +}) + const getHostKey = host => `${host.name}##${host.type}##${host.address}`; const diffRecords = (oldRecords, newRecords) => { @@ -69,13 +77,18 @@ const getDomainService = ({ cpanel }) => { const fetchRedirections = R.compose(then(R.map(redirectionToRecord)), cpanel.redirection.fetch); const addZoneRecord = lazyTask(R.compose( - cpanel.zone.add, + R.ifElse(R.propEq('type', 'MX'), + R.compose(cpanel.email.add, recordToEmailMx), + cpanel.zone.add + ), recordToZone, print(({ name }) => `Adding zone for ${name}...`), )); const removeZoneRecord = lazyTask(R.compose( - cpanel.zone.remove, - R.pick(['line']), + R.ifElse(R.propEq('type', 'MX'), + R.compose(cpanel.email.remove, recordToEmailMx), + R.compose(cpanel.zone.remove, R.pick(['line'])) + ), recordToZone, print(({ name }) => `Deleting zone for ${name}...`), )); diff --git a/utils/invalid-domains.json b/utils/invalid-domains.json new file mode 100644 index 000000000..4c49294be --- /dev/null +++ b/utils/invalid-domains.json @@ -0,0 +1,12 @@ +[ + "help", + "support", + "no-reply", + "noreply", + "notifications", + "notification", + "ww1", + "ww2", + "ww3", + "ww4" +] diff --git a/utils/lib/cpanel.js b/utils/lib/cpanel.js index b45b20ecd..29743295a 100644 --- a/utils/lib/cpanel.js +++ b/utils/lib/cpanel.js @@ -4,7 +4,6 @@ const qs = require('qs'); const { DOMAIN_API_HOST, DOMAIN_API_PORT, DOMAIN_USER, DOMAIN_API_KEY, DOMAIN_DOMAIN } = require('../constants'); const CpanelClient = (options) => { - // TODO: Make defaultQuery functional const api = ({ basePath = '', action = '' }) => (module, func, defaultQuery = {}) => (q = {}) => { const query = { ...defaultQuery, @@ -36,7 +35,7 @@ const CpanelClient = (options) => { return { zone: { // { customonly, domain } - // -> { cpanelresult: { data[{ class, ttl, name, line, Line, cname, type, record }] } } + // -> [{ class, ttl, name, line, Line, cname, type, record }] fetch: R.compose( p => p.then(R.pathOr([], ['cpanelresult', 'data'])), api2('ZoneEdit', 'fetchzone_records', { customonly: 1, domain: options.domain }) @@ -45,13 +44,14 @@ const CpanelClient = (options) => { // { name, type(A|CNAME), cname, address, ttl } // -> {} add: api2('ZoneEdit', 'add_zone_record', { domain: options.domain }), + // { line } // -> {} remove: api2('ZoneEdit', 'remove_zone_record', { domain: options.domain }), }, redirection: { // {} - // -> { domain, destination } + // -> [{ domain, destination }] fetch: R.compose( p => p.then(R.pathOr([], ['data'])), uapi('Mime', 'list_redirects'), @@ -60,6 +60,7 @@ const CpanelClient = (options) => { // { domain, redirect, type(permanent|tmp), redirect_wildcard(0|1), redirect(0|1|2) } // -> {} add: uapi('Mime', 'add_redirect'), + // { domain } // -> {} remove: uapi('Mime', 'delete_redirect'), @@ -67,6 +68,15 @@ const CpanelClient = (options) => { file: { write: uapi('Fileman', 'save_file_content', { from_charset: 'UTF-8', to_charset: 'UTF-8', fallback: 1 }), }, + email: { + // { domain, exchanger, priority } + // -> {} + add: uapi('Email', 'add_mx', { alwaysaccept: 'auto' }), + + // { domain, exchanger, priority } + // -> {} + remove: uapi('Email', 'delete_mx', { alwaysaccept: 'auto' }), + }, }; }; diff --git a/utils/validations.js b/utils/validations.js index 7365a2ab0..87455d019 100644 --- a/utils/validations.js +++ b/utils/validations.js @@ -1,19 +1,34 @@ const R = require('ramda'); const { VALID_RECORD_TYPES } = require('./constants'); const { or, and, validate, between, testRegex, withLengthEq, withLengthGte } = require('./helpers'); +const INVALID_NAMES = require('./invalid-domains.json'); -const isValidURL = testRegex(/^https?:\/\//ig); +const isValidURL = and([R.is(String), testRegex(/^https?:\/\//ig)]); -const validateCnameRecord = key => and([ - R.propSatisfies(R.is(String), key), - R.compose(withLengthEq(1), R.reject(R.equals('URL')), R.keys), - R.propSatisfies(withLengthGte(3), key), - R.propSatisfies(R.complement(isValidURL), key), +const isValidDomain = and([R.is(String), testRegex(/^(([a-z0-9\-]+)\.)+[a-z]+$/ig)]); + +const allowMXRecord = R.compose( + R.ifElse(R.includes('MX'), withLengthEq(2), withLengthEq(1)), + R.keys, +); + +const validateCnameRecord = type => and([ + R.propIs(String, type), + R.compose(withLengthEq(1), R.keys), // CNAME cannot be used with any other record + R.propSatisfies(withLengthGte(4), type), + R.propSatisfies(isValidDomain, type), ]); -const validateARecord = key => and([ - R.compose(withLengthEq(1), R.keys), - R.propSatisfies(withLengthGte(1), key), +const validateARecord = type => and([ + R.propIs(Array, type), + allowMXRecord, + R.propSatisfies(withLengthGte(1), type), +]); + +const validateMXRecord = type => and([ + R.propIs(Array, type), + R.propSatisfies(withLengthGte(1), type), + R.propSatisfies(R.all(isValidDomain), type), ]); const validateDomainData = validate({ @@ -24,6 +39,7 @@ const validateDomainData = validate({ and([ R.compose(between(2, 100), R.length), testRegex(/^[a-z0-9-]+$/g), + R.complement(R.includes(R.__, INVALID_NAMES)), ]) ]), }, @@ -35,7 +51,7 @@ const validateDomainData = validate({ R.is(Object), R.complement(R.isEmpty), R.where({ - username: and([ R.is(String), withLengthGte(1) ]), + username: and([R.is(String), withLengthGte(1)]), email: R.is(String), }), ]), @@ -46,13 +62,15 @@ const validateDomainData = validate({ R.is(Object), R.compose(R.isEmpty, R.difference(R.__, VALID_RECORD_TYPES), R.keys), R.cond([ - [R.has('CNAME'), validateCnameRecord('CNAME')], - [R.has('A'), validateARecord('A')], - [R.has('URL'), R.propSatisfies(isValidURL, 'URL')], + [R.has('CNAME'), validateCnameRecord('CNAME')], + [R.has('A'), validateARecord('A')], + [R.has('URL'), R.propSatisfies(isValidURL, 'URL')], + [R.has('MX'), validateMXRecord('MX')], + [R.has('TXT'), R.propSatisfies(R.is(String), 'TXT')], [R.T, R.T], ]), ]), }, }); -module.exports = { validateDomainData }; +module.exports = { validateDomainData, isValidDomain };