From 17f8bec34d642879f1f6d1dab13e445861ecc3d5 Mon Sep 17 00:00:00 2001 From: William Mantly Date: Thu, 15 Aug 2024 18:51:36 -0400 Subject: [PATCH] Polished off DNS api models --- nodejs/models/dns_provider.js | 100 +++++++++++++-------- nodejs/models/dns_provider/cloudflare.js | 86 +++++++++--------- nodejs/models/dns_provider/common.js | 97 ++++++++++++++------ nodejs/models/dns_provider/digitalocean.js | 66 ++++---------- nodejs/models/dns_provider/porkbun.js | 98 +++++++++----------- nodejs/models/host.js | 41 +++++++-- 6 files changed, 276 insertions(+), 212 deletions(-) diff --git a/nodejs/models/dns_provider.js b/nodejs/models/dns_provider.js index 16c6a38..76af706 100644 --- a/nodejs/models/dns_provider.js +++ b/nodejs/models/dns_provider.js @@ -31,7 +31,7 @@ class Domain extends Table{ try{ domain = tldExtract(domain).domain; }catch{} - + let instance = await super.get(domain, ...args); return instance; @@ -47,16 +47,17 @@ class Domain extends Table{ return results; } - // toJSON(){ - // return { - // ...super.toJSON(), - // provider: { - // displayName: this.provider.constructor.displayName, - // displayIconUni: this.provider.constructor.displayIconUni, - // displayIconHtml: this.provider.constructor.displayIconHtml, - // } - // } - // } + async getRecords(...args){ + return await this.provider.api.getRecords(this, ...args); + } + + async createRecord(...args){ + return await this.provider.api.createRecord(this, ...args); + } + + async deleteRecords(...args){ + return await this.provider.api.deleteRecords(this, ...args); + } } Domain.register(ModelPs(Domain)) @@ -82,24 +83,47 @@ class DnsProvider extends Table{ let Provider = providers[provider]; let _keyMap = {...this._keyMap, ...Provider._keyMap}; - return ({ + let cls = ({ [this.name] : class extends this { static _keyMap = _keyMap; static Provider = Provider; } })[this.name]; + + Object.assign(cls.prototype, Provider) + + return cls } static async create(data, ...args){ - let __intraModel = this.__intraModel(data.dnsProvider) - let provider = new __intraModel.Provider(data, ...args); + let Provider; + try{ + let __intraModel = this.__intraModel(data.dnsProvider); + Provider = __intraModel.Provider; - return await super.create.call(__intraModel, data, ...args); + // This is here test if the given API key is valid + let provider = new __intraModel.Provider(data, ...args); + let domains = await provider.listDomains(); + + let instance = await super.create.call(__intraModel, data, ...args); + await instance.updateDomains(domains); + + return instance; + }catch(error){ + if(error.name === 'UnauthorizedDnsApi'){ + let keys = []; + console.log('Provider', Provider) + for(let key in Provider._keyMap){ + keys.push({'key': key, message: 'Invalid Key'}) + } + throw this.errors.ObjectValidateError(keys, "API rejected key"); + } + } } static async get(data, ...args){ let instance = await super.get(data, ...args); - let __intraModel = this.__intraModel(instance.dnsProvider) + let __intraModel = this.__intraModel(instance.dnsProvider); return await super.get.call(__intraModel, data, ...args); } @@ -118,30 +142,33 @@ class DnsProvider extends Table{ return out; } - async updateDomains(){ - let domains = await this.provider.listDomains(); + get api(){ + return new this.constructor.Provider(this); + } + + async listDomains(){ + return this.api.listDomains() + } + + async updateDomains(domains){ + domains = domains || await this.listDomains(); for(let domain of domains){ if(!(await Domain.exists(domain.domain))){ await Domain.create({ created_by: this.created_by, domain: domain.domain, dnsProvider_id: this.id, - zoneId: domain.id, + zoneId: domain.zoneId, }); } } } - async getDomains(){ - return Domain.getByProviderId(this.id); - } - async remove(){ - let id = this.id; - let instance = await super.remove(); - for(let domain of await this.getDomains()){ + for(let domain of await this.domains){ await domain.remove(); } + let instance = await super.remove(); return instance; } @@ -160,22 +187,25 @@ DnsProvider.register(ModelPs(DnsProvider)) if(require.main === module){(async function(){try{ const conf = require('../conf'); - console.log(Table.models) + // console.log(await DnsProvider.findall()); - // console.log(await Domain.listDetail()) + let provider = await DnsProvider.get('e8443e03ac503c7b'); - // console.log(JSON.stringify(await Domain.get('ipa.wtf'), null, 2)) + console.log(await provider.listDomains()) - // console.log(JSON.stringify(await Domain.listDetail() ,null, 2)) - console.log(JSON.stringify(await Table.models.DnsProvider.listDetail() ,null, 2)) + let domain = await Domain.get('holycore.quest') // pork + // let domain = await Domain.get('rm-rf.stream') // DO + // let domain = await Domain.get('test.wtf') // CF + // console.log(await domain.createRecord({type: 'TXT', name: 'apitewefweefwsewft222', data:'hiiiiiii'})) - // console.log(await Domain.getByProviderId('e8443e03ac503c7b')); + let txtRecords = await domain.getRecords({type: 'TXT'}); + console.log(txtRecords.map(i=>`${i.name}: ${i.data}`)) + // console.log(await domain.deleteRecords({type: 'TXT'})) - // console.log(aw) - - process.exit(0) }catch(error){ console.log('IIFE Error:', error); +}finally{ + process.exit(0); }})()} diff --git a/nodejs/models/dns_provider/cloudflare.js b/nodejs/models/dns_provider/cloudflare.js index b9b77c8..c44abe8 100644 --- a/nodejs/models/dns_provider/cloudflare.js +++ b/nodejs/models/dns_provider/cloudflare.js @@ -1,7 +1,7 @@ 'use strict'; const axios = require('axios'); -const {dnsErrors, DnsApi} = require('./common'); +const {DnsApi} = require('./common'); //like the options obj will always use domain data and type // change content to data @@ -32,70 +32,78 @@ class CloudFlare extends DnsApi{ if(!['A', 'MX', 'CNAME', 'ALIAS', 'TXT', 'NS', 'AAAA', 'SRV', 'TLSA', 'CAA', 'HTTPS', 'SVCB'].includes(type)) throw new Error(`${this.constructor.name} API: Invalid 'type' passed`) } - __parseName(domain, name){ - if(name && !name.endsWith('.'+domain)){ - return `${name}.${domain}` - } - return name; - }; - async axios(method, ...args){ try{ let a = axios.create({ baseURL: 'https://api.cloudflare.com/client/v4/zones', headers: {Authorization: `Bearer ${this.token}`} }); - // console.log(a) - // console.log(...args) + return await a[method](...args); }catch(error){ - console.log(error) if(!error.response) throw error; - if(error.response.data || error.response.data.id === 'Unauthorized'){ - throw dnsErrors.unauthorized(this); + if(error.response.data && error.response.data.errors[0].code == 10000){ + throw this.errors.unauthorized(); } - throw dnsErrors.other(this, error.response.status, error.response.data.message); + throw this.errors.other(error.response.status, error.response.data.errors[0].message, error.response.data.errors[0].code, error); } } - async listDomains(){ let res = await this.axios('get'); - // return res.data.result; + for(let domain of res.data.result){ domain.domain = domain.name - domain.id = domain.id + domain.zoneId = domain.id } + return res.data.result; } + /* + The API and the generic class interface have different opinions of what keys + hold what data, the __parseOptions and __pastseRes normal the keys to what + the class expects + + What the the API calls it : What the class wants it as. + */ + __apiKeyMap = { + 'content': 'data', + } + //get records - async getRecords(domain, options={}){ - this.__typeCheck(options.type); - //like the options obj will always use domain data and type - // change content to data - // change name to domain + async getRecords(domain, options){ + let res = await this.axios('get', + `${domain.zoneId}/dns_records`, + ); + let records = this.__parseRes(res.data.result); - let res = await this.axios('get', `${domain.zoneId}/dns_records`, {params: options}) - - for (let record of res.data.result){ - record.domain = record.name - record.data = record.content - } - return res.data.result; + if(!options) return records; + return records.filter((record)=>{ + let matchCount = 0 + for(let key in options){ + if(record[key] === options[key] && ++matchCount === Object.keys(options).length){ + return true; + } + } + }); } async createRecord(domain, options){ - this.__typeCheck(options.type); - //POST zones/:zone_identifier/dns_records - // name , type , ip / content , ttl + try{ + let res = await this.axios('post', + `${domain.zoneId}/dns_records`, + this.__parseOptions(options, ['type', 'name', 'data']) + ); - if(!options.content) throw new Error(`${this.constructor.name} API: 'data' key is required for this action`) - - - let res = await this.axios('post', `${domain.zoneId}/dns_records`, options); - return res; + return this.__parseRes([res.data.result])[0]; + }catch(error){ + if(error.APIcode == 81058){ + return (await this.getRecords(domain, options))[0]; + } + throw error; + } } @@ -106,9 +114,7 @@ class CloudFlare extends DnsApi{ async deleteRecords(domain, options){ let records = await this.getRecords(domain, options) for(let record of records){ - let res = await this.deleteRecordById(domain, record.id); - // // console.log(record) - // console.log(record.id) + } return true; diff --git a/nodejs/models/dns_provider/common.js b/nodejs/models/dns_provider/common.js index 067d451..32f88a2 100644 --- a/nodejs/models/dns_provider/common.js +++ b/nodejs/models/dns_provider/common.js @@ -1,40 +1,31 @@ 'use strict'; -let dnsErrors = { - unauthorized(instance){ - let error = new Error('UnauthorizedDnsApi'); - error.name = 'UnauthorizedDnsApi'; - error.message = `Unauthorized call to ${instance.constructor ? instance.constructor.name : instance.name}`; - error.status = 424; - - return error; - }, - other(instance, status, message){ - let error = new Error('OtherDnsApiError'); - error.name = 'OtherDnsApiError'; - error.message = `DNS API Error ${instance.constructor ? instance.constructor.name : instance.name}: `; - error.status = 424; - - return error; - }, -} +const tldExtract = require('tld-extract').parse_host; class DnsApi{ errors = { - unauthorized(instance){ + unauthorized: ()=>{ let error = new Error('UnauthorizedDnsApi'); error.name = 'UnauthorizedDnsApi'; - error.message = `Unauthorized call to ${instance.constructor ? instance.constructor.name : instance.name}`; + error.message = `Unauthorized call to ${this.constructor.name}`; error.status = 424; return error; }, - other(instance, status, message){ + invalidInput: (keys)=>{ + let error = new Error('InvalidInput'); + error.name = 'InvalidInput'; + error.message = `Required keys missing: ${keys.join(', ')}` + + return error + + }, + other: (status, message, APIcode)=>{ let error = new Error('OtherDnsApiError'); error.name = 'OtherDnsApiError'; - error.message = `DNS API Error ${instance.constructor ? instance.constructor.name : instance.name}: `; + error.message = `DNS API Error ${this.constructor.name}: ${status} ${message}`; error.status = 424; - + error.APIcode = APIcode; return error; }, } @@ -58,13 +49,69 @@ class DnsApi{ } } + /* + No instance data should ever be shared, so just give the static level inf + */ toJSON(){ - return this.constructor.toJSON() + return this.constructor.toJSON(); + } + + + __typeCheck(type){ + if(!['A', 'MX', 'CNAME', 'ALIAS', 'TXT', 'NS', 'AAAA', 'SRV', 'TLSA', 'CAA', 'HTTPS', 'SVCB'].includes(type)) throw new Error('PorkBun API: Invalid type passed') + } + + + /* + The API and the generic class interface have different opinions of what keys + hold what data, the __parseOptions and __pastseRes normal the keys to what + the class expects + + What the the API calls it : What the class wants it as. + */ + __apiKeyMap = {}; + + __parseOptions(options, keys){ + if(!options && !keys) return undefined; + + if(keys){ + let missingKeys = [] + for(let key of keys){ + if(!options[key]) missingKeys.push(key) + } + + if(missingKeys.length) throw this.errors.invalidInput(missingKeys); + } + + for(let [apiKey, clsKey] of Object.entries(this.__apiKeyMap)){ + if(options[clsKey]){ + options[apiKey] = options[clsKey]; + delete options[clsKey]; + } + } + + if(options.type) this.__typeCheck(options.type); + + return options; + } + + __parseRes(data){ + for(let item of data){ + for(let [apiKey, clsKey] of Object.entries(this.__apiKeyMap)){ + if(item[apiKey]){ + item[clsKey] = item[apiKey]; + } + } + try{ + item.name = tldExtract(item.name).sub + }catch{} + } + + return data; } } module.exports = { - dnsErrors, DnsApi, }; diff --git a/nodejs/models/dns_provider/digitalocean.js b/nodejs/models/dns_provider/digitalocean.js index 0b4f65c..264c827 100644 --- a/nodejs/models/dns_provider/digitalocean.js +++ b/nodejs/models/dns_provider/digitalocean.js @@ -1,7 +1,7 @@ 'use strict'; const axios = require('axios'); -const {dnsErrors, DnsApi} = require('./common'); +const {DnsApi} = require('./common'); class DigitalOcean extends DnsApi{ static _keyMap = { @@ -22,18 +22,6 @@ class DigitalOcean extends DnsApi{ this.token = token.token || token; } - __typeCheck(type){ - if(!type) return; - if(!['A', 'MX', 'CNAME', 'ALIAS', 'TXT', 'NS', 'AAAA', 'SRV', 'TLSA', 'CAA', 'HTTPS', 'SVCB'].includes(type)) throw new Error(`${this.constructor.name} API: Invalid 'type' passed`) - } - - __parseName(domain, name){ - if(name && !name.endsWith('.'+domain)){ - return `${name}.${domain}` - } - return name; - } - async axios(method, ...args){ try{ let a = axios.create({ @@ -44,48 +32,44 @@ class DigitalOcean extends DnsApi{ return await a[method](...args); }catch(error){ if(!error.response) throw error; - if(error.response.data || error.response.data.id === 'Unauthorized'){ - throw dnsErrors.unauthorized(this); + if(error.response.data && error.response.data.id === 'Unauthorized'){ + throw this.errors.unauthorized(); } - throw dnsErrors.other(this, error.response.status, error.response.data.message); + throw this.errors.other(error.response.status, error.response.data.message); } } async listDomains(){ let res = await this.axios('get', '/domains'); - for(let domain of res.data.domains){ - domain.domain = domain.name - } - return res.data.domains; + + return this.__parseRes(res.data.domains); } - async getRecords(domain, options={}){ - this.__typeCheck(options.type); - let res = await this.axios('get', `/domains/${domain}/records`, {params: options}) - let records = []; + async getRecords(domain, options){ + options = this.__parseOptions(options); + let res = await this.axios('get', `/domains/${domain.domain}/records`, {params: options}) + let records = this.__parseRes(res.data.domain_records); + if(!options) return records; - for(let record of res.data.domain_records){ + return records.filter((record)=>{ let matchCount = 0 for(let key in options){ if(record[key] === options[key] && ++matchCount === Object.keys(options).length){ - records.push(record) + return true; } } - } - return records; + }); } async createRecord(domain, options){ - this.__typeCheck(options.type); - if(!options.data) throw new Error(`${this.constructor.name} API: 'data' key is required for this action`) - if(options.name) options.name = this.__parseName(domain, options.name); + options = this.__parseOptions(options, ['type', 'name', 'data']); + let res = await this.axios('post', `/domains/${domain.domain}/records`, options); - let res = await this.axios('post', `/domains/${domain}/records`, options); - return res.data; + return this.__parseRes([res.data.domain_record])[0]; } async deleteRecordById(domain, id){ - let res = await this.axios('delete', `/domains/${domain}/records/${id}`); + let res = await this.axios('delete', `/domains/${domain.domain}/records/${id}`); } async deleteRecords(domain, options){ @@ -99,17 +83,3 @@ class DigitalOcean extends DnsApi{ } module.exports = DigitalOcean; - - -if(require.main === module){(async function(){try{ - // const conf = require('../conf'); - - // console.log(await digi.listDomains()) - - // console.log('make', await digi.createRecord('rm-rf.stream', {name:'_test', data: '890', type: "TXT"})) - // console.log('delete', await digi.deleteRecords('rm-rf.stream', {type: 'TXT'})) - // console.log('get', await digi.getRecords('rm-rf.stream', {type:'TXT', data:'890'})) - -}catch(error){ - console.log('IIFE Error:', error) -}})()} diff --git a/nodejs/models/dns_provider/porkbun.js b/nodejs/models/dns_provider/porkbun.js index a536da3..64347d8 100644 --- a/nodejs/models/dns_provider/porkbun.js +++ b/nodejs/models/dns_provider/porkbun.js @@ -1,7 +1,7 @@ 'use strict'; const axios = require('axios'); -const {dnsErrors, DnsApi} = require('./common'); +const {DnsApi} = require('./common'); class PorkBun extends DnsApi{ @@ -40,15 +40,16 @@ class PorkBun extends DnsApi{ secretapikey: this.secretApiKey, apikey: this.apiKey, }; - res = await axios.post(`'https://api.porkbun.com/api/json/v3'${url}`, data); + res = await axios.post(`https://api.porkbun.com/api/json/v3${url}`, data); return res; }catch(error){ if(!error.response) throw error; if(error.response.data.message.includes('Invalid API key')){ - throw dnsErrors.unauthorized(this); + throw this.errors.unauthorized(); } - throw dnsErrors.other(this, error.response.status, error.response.data.message) + // console.error('API error:', error) + throw this.errors.other(error.response.status, error.response.data.message) } } @@ -63,63 +64,65 @@ class PorkBun extends DnsApi{ return name; } + + /* + The API and the generic class interface have different opinions of what keys + hold what data, the __parseOptions and __pastseRes normal the keys to what + the class expects + + What the the API calls it : What the class wants it as. + */ + + __apiKeyMap = { + 'content': 'data' + } + async getRecords(domain, options){ let res = await this.post(`/dns/retrieve/${domain}`); - if(!options) return res.data.records; - if(options.data) options.content = options.data; + let records = this.__parseRes(res.data.records) + if(!options) return records; + options = this.__parseOptions(options); - if(options.type) this.__typeCheck(options.type); - if(options.name) options.name = this.__parseName(domain, options.name); - let records = []; - - for(let record of res.data.records){ + return records.filter((record)=>{ let matchCount = 0 - for(let option in options){ - if(record[option] === options[option] && ++matchCount === Object.keys(options).length){ - records.push(record) - break; + for(let key in options){ + if(record[key] === options[key] && ++matchCount === Object.keys(options).length){ + return true; } } + }); + } + + async createRecord(domain, options, force){ + if(force){ + await this.deleteRecords(domain, options) } - return records; + try{ + options = this.__parseOptions(options, ['type', 'name', 'data']); + let res = await this.post(`/dns/create/${domain}`, options); + + return res.data.result; + }catch(error){ + if(error.message && error.message.includes('We were unable to create the DNS record')){ + return (await this.getRecords(domain, options))[0]; + } + } } async deleteRecordById(domain, id){ - let res = await this.post(`/dns/delete/${domain}/${id}`); + let res = await this.post(`/dns/delete/${domain.domain}/${id}`); return res.data; } + async deleteRecords(domain, options){ let records = await this.getRecords(domain, options); - // console.log('PorkBun.deleteRecords', records) for(let record of records){ await this.deleteRecordById(domain, record.id) } } - async createRecord(domain, options){ - this.__typeCheck(options.type); - if(!options.content) throw new Error('PorkBun API: `content` key is required for this action') - // if(options.name) options.name = this.__parseName(domain, options.name); - // console.log('PorkBun.createRecord to send:', domain, options) - - let res = await this.post(`/dns/create/${domain}`, options); - return res.data; - } - - async createRecordForce(domain, options){ - let {content, ...removed} = options; - // console.log('new options', removed) - let records = await this.getRecords(domain, removed); - // console.log('createRecordForce', records) - if(records.length){ - // console.log('calling delete on', records[0].id) - // process.exit(0) - await this.deleteRecordById(domain, records[0].id) - } - return await this.createRecord(domain, options) - } async listDomains(){ let res = await this.post(`/domain/listAll`, {"includeLabels": "yes"}); @@ -128,18 +131,3 @@ class PorkBun extends DnsApi{ } module.exports = PorkBun; - - -if(require.main === module){(async function(){try{ - const conf = require('../conf'); - - // let porkBun = new PorkBun(conf.porkBun.apiKey, conf.porkBun.secretApiKey); - - // console.log(await porkBun.listDomains()) - - // console.log(await porkBun.deleteRecordById('holycore.quest', '415509355')) - // console.log('IIFE', await porkBun.createRecordForce('holycore.quest', {type:'A', name: 'testapi', content: '127.0.0.5'})) - // console.log('IIFE', await porkBun.getRecords('holycore.quest', {type:'A', name: 'testapi'})) -}catch(error){ - console.log('IIFE Error:', error) -}})()} diff --git a/nodejs/models/host.js b/nodejs/models/host.js index fe9548d..ca04014 100755 --- a/nodejs/models/host.js +++ b/nodejs/models/host.js @@ -1,12 +1,18 @@ 'use strict'; const Table = require('.'); +const {Domain} = require('.').models; const ModelPs = require('../utils/model_pubsub'); const tldExtract = require('tld-extract').parse_host; const LetsEncrypt = require('../utils/letsencrypt'); const conf = require('../conf'); +const letsEncrypt = new LetsEncrypt({ + directoryUrl: conf.environment === "production" ? + LetsEncrypt.AcmeClient.directory.letsencrypt.production : + LetsEncrypt.AcmeClient.directory.letsencrypt.staging, +}); class Host extends Table{ static _key = 'host'; @@ -22,7 +28,7 @@ class Host extends Table{ 'targetssl': {isRequired: false, default: false, type: 'boolean'}, 'is_cache': {default: false, isRequired: false, type: 'boolean',}, 'is_wildcard': {default: false, isRequired: false, type: 'boolean',}, - 'wildcard_status': {isRequired: false, type: 'string', min: 3, max: 500, default: 'Requesting'}, + 'wildcard_status': {isRequired: false, type: 'string', min: 3, max: 500}, 'wildcard_parent': {isRequired: false, type: 'string', min: 3, max: 500}, 'wildcard_expires': {isRequired: false, type: 'number'}, 'domain': {model: 'Domain', rel: 'one'}, @@ -33,7 +39,6 @@ class Host extends Table{ static async addCache(host, parentOBJ){ try{ - console.log('addCache host:', host, 'parentOBJ host', parentOBJ.host) parentOBJ = await this.get(parentOBJ.host); @@ -83,6 +88,8 @@ class Host extends Table{ static async create(data, ...args){ try{ + if(data.is_wildcard) await this.validateWildcardCreate(data, args); + let out = await super.create(data, ...args); await this.buildLookUpObj(); if(out.is_wildcard) out.createWildcardCert(); @@ -94,6 +101,17 @@ class Host extends Table{ } } + static async validateWildcardCreate(data, ...args){ + try{ + if(!data.host.startsWith('*.')) throw new Error('not wild card'); + await Domain.get(data.host); + }catch(error){ + console.log('validateWildcardCreate error', error) + if(error.status === 404) error.message = "No matching DNS provider registered" + throw this.errors.ObjectValidateError([{key: 'host', message: error.message}]); + } + } + async createWildcardCert(){ if(!this.host.startsWith('*.')) throw new Error('not wild card'); @@ -111,12 +129,11 @@ class Host extends Table{ try{ let parts = tldExtract(authz.identifier.value); - let res = await porkBun.createRecordForce( - parts.domain, + let res = await host.domain.createRecord( { type:'TXT', name: `_acme-challenge${parts.sub ? `.${parts.sub}` : ''}`, - content: `${keyAuthorization}` + data: `${keyAuthorization}` } ); }catch(error){ @@ -157,8 +174,7 @@ class Host extends Table{ }) try{ let parts = tldExtract(authz.identifier.value); - await porkBun.deleteRecords( - parts.domain, + await host.domain.deleteRecords( { type:'TXT', name: `_acme-challenge${parts.sub ? `.${parts.sub}` : '' @@ -334,9 +350,16 @@ class Cached extends Table{ module.exports = {Host: ModelPs(Host)}; -(async function(){ +if(require.main === module){(async function(){ try{ await Host.lookUpReady(); + + let host = await Host.get('*.new.test.wtf') + + console.log('host', host.domain.provider.api); + + + // let res = await Host.create({ // host: '*.test.holycore.quest', // ip: '192.168.1.47', @@ -385,4 +408,4 @@ try{ }catch(error){ console.log('IIFE test area error:', error) } -})() \ No newline at end of file +})()}