Skip to content

Commit cbc3bbb

Browse files
committed
init
0 parents  commit cbc3bbb

File tree

12 files changed

+407
-0
lines changed

12 files changed

+407
-0
lines changed

.gitignore

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
2+
\.idea/
3+
4+
dist/
5+
6+
node_modules

.npmrc

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
package-lock=false
2+
registry=https://npm.interactive.training

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
# it-acme-client

package.json

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
{
2+
"name": "it-acme-client",
3+
"version": "0.0.1",
4+
"description": "",
5+
"main": "dist/index.js",
6+
"scripts": {
7+
"build": "tsc"
8+
},
9+
"author": "Interactive Training",
10+
"license": "MIT",
11+
"devDependencies": {
12+
"typescript": "^3.4.3"
13+
},
14+
"dependencies": {
15+
"@google-cloud/storage": "^2.5.0",
16+
"@types/node": "^11.13.4",
17+
"cloudflare": "^2.4.1",
18+
"letiny": "^0.2.1-1",
19+
"reflect-metadata": "^0.1.13",
20+
"typedi": "^0.8.0"
21+
}
22+
}

src/helpers.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
/**
2+
*
3+
* @param {T[]} arr
4+
* @param {number} depth
5+
* @return {T}
6+
*/
7+
export function flatten<T>(arr: T[], depth: number = 1): T {
8+
return (arr) ? arr.reduce((a, v) => a.concat(depth > 1 && Array.isArray(v) ? flatten(v, depth - 1) : v), []) as any : [];
9+
}

src/index.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import "reflect-metadata";
2+
import * as letiny from 'letiny';
3+
import {IConfig} from './types/interfaces';
4+
import {Container} from "typedi";
5+
import {GCloudStorageService} from './services/gCloudStorageService';
6+
import {ConfigService} from './services/configService';
7+
import {CloudflareService} from './services/cloudflareService';
8+
9+
export class itAcmeClient {
10+
config: ConfigService;
11+
gCloudStorage: GCloudStorageService;
12+
cloudflareService: CloudflareService;
13+
14+
constructor(config: IConfig) {
15+
Container.set(ConfigService, new ConfigService(config));
16+
Container.set(GCloudStorageService, new GCloudStorageService());
17+
Container.set(CloudflareService, new CloudflareService());
18+
this.config = Container.get(ConfigService);
19+
this.gCloudStorage = Container.get(GCloudStorageService);
20+
this.cloudflareService = Container.get(CloudflareService);
21+
}
22+
23+
getCert(): Promise<{ key: string, cert: string }> {
24+
return new Promise(async (resolve1) => {
25+
const currentCert = await this.gCloudStorage.read('cert');
26+
if (!currentCert || !(currentCert && (new Date().getTime() - (letiny.getExpirationDate(currentCert) as Date).getTime()) / (1000 * 60 * 60 * 24.0))) {
27+
letiny.getCert({
28+
method: 'dns-01',
29+
email: this.config.email,
30+
domains: this.config.domain,
31+
url: (this.config.acmeServer === 'staging') ? 'https://acme-staging.api.letsencrypt.org' : 'https://acme-v01.api.letsencrypt.org',
32+
challenge: async (domain, _, data, done) => {
33+
await this.cloudflareService.removeChallengeRecord();
34+
this.cloudflareService.addChallengeRecord(data).then(async () => {
35+
await this.cloudflareService.removeChallengeRecord();
36+
done();
37+
});
38+
},
39+
agreeTerms: this.config.agreeTerms
40+
}, async (err, cert, key, caCert, accountKey) => {
41+
await this.gCloudStorage.write('cert', cert);
42+
await this.gCloudStorage.write('caCert', caCert);
43+
await this.gCloudStorage.write('privateKey', key);
44+
await this.gCloudStorage.write('accountKey', accountKey);
45+
resolve1({
46+
key: key,
47+
cert: cert + `
48+
` + caCert
49+
})
50+
});
51+
} else {
52+
resolve1({
53+
key: await this.gCloudStorage.read('privateKey'),
54+
cert: `${currentCert}
55+
${(await this.gCloudStorage.read('caCert'))}`
56+
})
57+
}
58+
})
59+
}
60+
61+
}

src/services/cloudflareService.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import {Container, Service} from 'typedi';
2+
import {ConfigService} from './configService';
3+
import {CloudflareApi, ICloudflareResourceZone} from '../types/cloudflare';
4+
import * as cloudflare from 'cloudflare';
5+
import * as dns from "dns";
6+
import {flatten} from '../helpers';
7+
8+
@Service()
9+
export class CloudflareService {
10+
private config: ConfigService;
11+
private cf: CloudflareApi;
12+
private primaryZone: ICloudflareResourceZone;
13+
private challengeData: string;
14+
15+
constructor() {
16+
this.config = Container.get(ConfigService);
17+
if (this.config && this.config.cloudflare) {
18+
this.cf = cloudflare({
19+
email: this.config.cloudflare.email,
20+
key: this.config.cloudflare.apiKey
21+
});
22+
}
23+
}
24+
25+
async getZone(): Promise<ICloudflareResourceZone> {
26+
if (!this.primaryZone) {
27+
return (await this.cf.zones.browse({
28+
page: 1,
29+
per_page: 1000
30+
})).result.find(el => el.name.includes(this.config.getParentDomain()));
31+
}
32+
return this.primaryZone;
33+
}
34+
35+
async removeChallengeRecord(): Promise<void> {
36+
const zoneId = (await this.getZone()).id;
37+
const findRecord = (await this.cf.dnsRecords.browse(zoneId, {
38+
page: 1,
39+
per_page: 1000
40+
})).result.find(el => el.type === 'TXT' && el.name.includes('_acme-challenge'));
41+
if (findRecord) {
42+
await this.cf.dnsRecords.del(zoneId, findRecord.id);
43+
}
44+
}
45+
46+
locallyVerifyTxtRecord(count = 0) {
47+
return new Promise((resolve) => {
48+
dns.resolveTxt(`_acme-challenge.${this.config.domain}`, (err, addresses) => {
49+
// if (err) console.error(err);
50+
const values = flatten(addresses, 2);
51+
if (values.find(el => el.includes(this.challengeData))) {
52+
console.log('domain verified!', values);
53+
resolve();
54+
} else {
55+
setTimeout(async () => {
56+
console.log('(' + count + ') verifying acme challenge...');
57+
resolve((await this.locallyVerifyTxtRecord(count + 1)));
58+
}, 8000);
59+
}
60+
});
61+
})
62+
};
63+
64+
addChallengeRecord(data: string) {
65+
this.challengeData = data;
66+
return new Promise(async (resolve) => {
67+
await this.cf.dnsRecords.add((await this.getZone()).id, {
68+
type: 'TXT',
69+
name: `_acme-challenge.${this.config.domain}`,
70+
content: this.challengeData,
71+
proxied: false,
72+
ttl: 1
73+
});
74+
this.locallyVerifyTxtRecord().then(() => resolve());
75+
})
76+
}
77+
78+
}

src/services/configService.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import {Service} from 'typedi';
2+
import {ICloudflareConfig, IConfig, IFileNameConfig, IGoogleCloudConfig, TFileName} from '../types/interfaces';
3+
4+
@Service()
5+
export class ConfigService implements IConfig {
6+
public acmeServer: 'prod' | 'staging';
7+
public domain: string;
8+
public email: string;
9+
public cloudflare: ICloudflareConfig;
10+
public googleCloud: IGoogleCloudConfig;
11+
public fileNames: IFileNameConfig;
12+
public agreeTerms: boolean;
13+
14+
constructor(config: IConfig) {
15+
Object.keys(config).forEach((key) => {
16+
this[key] = config[key];
17+
});
18+
}
19+
20+
getFileName(file: TFileName) {
21+
return this.fileNames[file];
22+
}
23+
24+
getParentDomain() {
25+
const parentDomain = this.domain.split('.');
26+
return `${parentDomain[parentDomain.length - 2]}.${parentDomain[parentDomain.length - 1]}`;
27+
}
28+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import {Container, Service} from 'typedi';
2+
import {Storage} from '@google-cloud/storage';
3+
import * as fs from "fs";
4+
import {ConfigService} from './configService';
5+
import {TFileName} from '../types/interfaces';
6+
7+
@Service()
8+
export class GCloudStorageService {
9+
private config: ConfigService;
10+
private storage: Storage;
11+
12+
constructor() {
13+
this.config = Container.get(ConfigService);
14+
this.storage = new Storage({
15+
projectId: this.config.googleCloud.projectId,
16+
credentials: {
17+
client_email: this.config.googleCloud.credentials.email,
18+
private_key: this.config.googleCloud.credentials.privateKey
19+
}
20+
});
21+
}
22+
23+
read(file: TFileName): Promise<string | undefined> {
24+
return new Promise(async (resolve) => {
25+
const fileName = this.config.getFileName(file);
26+
let buffer = '';
27+
const exists = (await this.storage.bucket(this.config.googleCloud.bucketName).file(fileName).exists())[0];
28+
if (exists) {
29+
this.storage.bucket(this.config.googleCloud.bucketName).file(fileName).createReadStream()
30+
.on('data', d => buffer += d)
31+
.on('end', () => resolve(buffer));
32+
} else {
33+
resolve(undefined);
34+
}
35+
})
36+
}
37+
38+
async write(file: TFileName, fileContent: string): Promise<string> {
39+
const fileName = this.config.getFileName(file);
40+
fs.writeFileSync(fileName, fileContent, {encoding: 'utf-8'});
41+
await this.storage.bucket(this.config.googleCloud.bucketName).upload(fileName);
42+
fs.unlinkSync(fileName);
43+
return fileContent;
44+
}
45+
}

src/types/cloudflare.d.ts

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
export interface ICloudflarePagination {
2+
page: number;
3+
per_page: number;
4+
}
5+
6+
export interface ICloudflareResultInfo {
7+
page: number;
8+
per_page: number;
9+
total_pages: number;
10+
count: number;
11+
total_count: number;
12+
}
13+
14+
export interface ICloudflareResponse<Resource> {
15+
result: Resource;
16+
result_info?: ICloudflareResultInfo;
17+
success: boolean;
18+
errors: any[];
19+
messages: any[];
20+
}
21+
22+
23+
export interface ICloudflareResourceZone {
24+
id: string;
25+
name: string;
26+
status: string;
27+
paused: boolean;
28+
type: string;
29+
development_mode: number;
30+
31+
// TODO name_servers
32+
name_servers: any[];
33+
34+
// TODO original_name_servers
35+
original_name_servers: any[];
36+
37+
// TODO original_registrar
38+
original_registrar: any;
39+
40+
// TODO original_dnshost
41+
original_dnshost: any;
42+
43+
modified_on: Date;
44+
created_on: Date;
45+
activated_on: Date;
46+
47+
// TODO meta
48+
meta: any;
49+
50+
// TODO owner
51+
owner: any;
52+
53+
// TODO account
54+
account: any;
55+
56+
// TODO permissions
57+
permissions: any[];
58+
59+
// TODO plan
60+
plan: any;
61+
}
62+
63+
export class CloudflareZones {
64+
browse: (pagination?: Partial<ICloudflarePagination>) => Promise<ICloudflareResponse<ICloudflareResourceZone[]>>;
65+
}
66+
67+
export interface ICloudflareResourceDnsRecord {
68+
id: string;
69+
type: string;
70+
name: string;
71+
content: string;
72+
proxiable: boolean;
73+
proxied: boolean;
74+
ttl: number;
75+
locked: boolean;
76+
zone_id: string;
77+
zone_name: string;
78+
modified_on: Date;
79+
created_on: Date;
80+
81+
// TODO meta
82+
meta: any;
83+
}
84+
85+
export interface ICloudflareDeleteResource {
86+
id: string;
87+
}
88+
89+
export class CloudflareDnsRecords {
90+
browse: (zoneId: string, pagination?: Partial<ICloudflarePagination>) => Promise<ICloudflareResponse<ICloudflareResourceDnsRecord[]>>;
91+
del: (zoneId: string, dnsRecordId: string) => Promise<ICloudflareResponse<ICloudflareDeleteResource>>;
92+
add: (zoneId: string, record: Partial<ICloudflareResourceDnsRecord>) => Promise<ICloudflareResponse<ICloudflareResourceDnsRecord>>;
93+
}
94+
95+
export class CloudflareApi {
96+
zones: CloudflareZones;
97+
dnsRecords: CloudflareDnsRecords;
98+
}
99+
100+
export declare function cloudflare(credentials: { email: string, key: string }): CloudflareApi;

0 commit comments

Comments
 (0)