keys in file, basic zonefile validation, rndc
This commit is contained in:
parent
2dbb759b14
commit
10ac1b0d80
1
.gitignore
vendored
1
.gitignore
vendored
@ -1,2 +1,3 @@
|
|||||||
/node_modules/
|
/node_modules/
|
||||||
|
/keys.json
|
||||||
*.zone
|
*.zone
|
||||||
|
@ -1,12 +1,17 @@
|
|||||||
import { CachedZone, DNSRecord, DNSZone, SOARecord } from "../models/interfaces";
|
import { CachedZone, SOARecord } from '../models/interfaces';
|
||||||
import { readZoneFile } from "./reader";
|
import { readZoneFile } from './reader';
|
||||||
import { DNSRecordType } from "./records";
|
import { DNSRecordType } from './records';
|
||||||
import { writeZoneFile } from "./writer";
|
import { ReloadExecutor } from './rndc';
|
||||||
|
import { ValidatorExecutor } from './validator';
|
||||||
|
|
||||||
export class DNSCache {
|
export class DNSCache {
|
||||||
private cached: Record<string, CachedZone> = {};
|
private cached: Record<string, CachedZone> = {};
|
||||||
|
|
||||||
constructor(private ttl = 1600) {}
|
constructor(
|
||||||
|
private rndc: ReloadExecutor,
|
||||||
|
private validator: ValidatorExecutor,
|
||||||
|
private ttl = 1600
|
||||||
|
) {}
|
||||||
|
|
||||||
has(name: string): boolean {
|
has(name: string): boolean {
|
||||||
return this.cached[name] != null;
|
return this.cached[name] != null;
|
||||||
@ -47,10 +52,11 @@ export class DNSCache {
|
|||||||
if (!zone) {
|
if (!zone) {
|
||||||
throw new Error('No such cached zone file!');
|
throw new Error('No such cached zone file!');
|
||||||
}
|
}
|
||||||
await writeZoneFile(zone.zone, zone.file);
|
|
||||||
|
await this.validator.validateAndSave(name, zone);
|
||||||
}
|
}
|
||||||
|
|
||||||
async update(name: string, newZone?: CachedZone): Promise<void> {
|
async update(name: string, newZone?: CachedZone, skipReload = false): Promise<void> {
|
||||||
let zone: CachedZone | null;
|
let zone: CachedZone | null;
|
||||||
if (newZone) {
|
if (newZone) {
|
||||||
zone = newZone;
|
zone = newZone;
|
||||||
@ -67,6 +73,14 @@ export class DNSCache {
|
|||||||
soa.serial = Math.floor(Date.now() / 1000);
|
soa.serial = Math.floor(Date.now() / 1000);
|
||||||
|
|
||||||
this.set(name, zone);
|
this.set(name, zone);
|
||||||
return this.save(name);
|
await this.save(name);
|
||||||
|
|
||||||
|
if (!skipReload) {
|
||||||
|
try {
|
||||||
|
await this.rndc.reload(name);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('%s automatic zone reload failed:', name, e.stack);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
35
src/dns/rndc.ts
Normal file
35
src/dns/rndc.ts
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import { exec } from 'child_process';
|
||||||
|
|
||||||
|
export class ReloadExecutor {
|
||||||
|
constructor(
|
||||||
|
private host = '127.0.0.1',
|
||||||
|
private port = 953,
|
||||||
|
private keyFile = 'rndc.key'
|
||||||
|
) {}
|
||||||
|
|
||||||
|
static fromEnvironment(): ReloadExecutor {
|
||||||
|
return new ReloadExecutor(
|
||||||
|
process.env.RNDC_SERVER,
|
||||||
|
parseInt(process.env.RNDC_PORT || '953', 10),
|
||||||
|
process.env.RNDC_KEYFILE
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private async rndc(command: string, data: string): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
exec(`rndc -k ${this.keyFile} -s ${this.host} -p ${this.port} ${command} ${data}`,
|
||||||
|
(error, stdout) => {
|
||||||
|
if (error) {
|
||||||
|
reject(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve(stdout);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async reload(domain: string): Promise<string> {
|
||||||
|
return this.rndc('reload', domain);
|
||||||
|
}
|
||||||
|
}
|
58
src/dns/validator.ts
Normal file
58
src/dns/validator.ts
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
import * as fs from 'fs/promises';
|
||||||
|
import { CachedZone, DNSRecord } from '../models/interfaces';
|
||||||
|
import { exec } from 'child_process';
|
||||||
|
import { writeZoneFile } from './writer';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
// TODO: in-depth validation for record types
|
||||||
|
|
||||||
|
const forbiddenCharacters = ['\n', '\r'];
|
||||||
|
const forbiddenOutsideStr = ['$', ';'];
|
||||||
|
|
||||||
|
export class ValidatorExecutor {
|
||||||
|
constructor() {}
|
||||||
|
|
||||||
|
public validateRecord(record: DNSRecord): boolean {
|
||||||
|
for (const char of forbiddenCharacters) {
|
||||||
|
if (record.name.includes(char) || record.value.includes(char)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const char of forbiddenOutsideStr) {
|
||||||
|
if (record.name.includes(char)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async validateZonefile(domain: string, file: string): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
exec(`named-checkzone ${domain} ${file}`,
|
||||||
|
(error, stdout) => {
|
||||||
|
if (error) {
|
||||||
|
const errorFull = stdout.split('\n')[0].split(':');
|
||||||
|
reject(new Error(`Validation error: ${errorFull[0]}: ${errorFull.slice(2).join(':')}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve(stdout);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async validateAndSave(name: string, zone: CachedZone): Promise<void> {
|
||||||
|
const tempfile = path.join(process.cwd(), `.${name}-${Date.now()}.zone`);
|
||||||
|
await writeZoneFile(zone.zone, tempfile);
|
||||||
|
try {
|
||||||
|
await this.validateZonefile(name, tempfile);
|
||||||
|
} catch (e) {
|
||||||
|
await fs.unlink(tempfile);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
// TODO: cross-device move
|
||||||
|
await fs.rename(tempfile, zone.file);
|
||||||
|
}
|
||||||
|
}
|
86
src/index.ts
86
src/index.ts
@ -3,11 +3,15 @@ import 'express-async-errors';
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { DNSCache } from './dns/cache';
|
import { DNSCache } from './dns/cache';
|
||||||
import { DNSRecordType } from './dns/records';
|
import { DNSRecordType } from './dns/records';
|
||||||
|
import { ReloadExecutor } from './dns/rndc';
|
||||||
|
import { ValidatorExecutor } from './dns/validator';
|
||||||
import { createZoneFile } from './dns/writer';
|
import { createZoneFile } from './dns/writer';
|
||||||
import { fromRequest } from './ip/from-request';
|
import { fromRequest } from './ip/from-request';
|
||||||
|
import { Keys } from './keys';
|
||||||
import { CachedZone } from './models/interfaces';
|
import { CachedZone } from './models/interfaces';
|
||||||
|
|
||||||
const port = parseInt(process.env.PORT || '9129', 10);
|
const port = parseInt(process.env.PORT || '9129', 10);
|
||||||
|
const cacheTTL = parseInt(process.env.CACHE_TTL || '2629746', 10);
|
||||||
const dir = process.env.ZONEFILES || '.';
|
const dir = process.env.ZONEFILES || '.';
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
@ -15,15 +19,14 @@ const api = express.Router();
|
|||||||
|
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
|
|
||||||
const cache = new DNSCache();
|
const keys = new Keys();
|
||||||
const authentic: {[x: string]: string} = {
|
const rndc = ReloadExecutor.fromEnvironment();
|
||||||
'testing-token-auth-aaaa': 'lunasqu.ee',
|
const validator = new ValidatorExecutor();
|
||||||
'aaa': 'lol'
|
const cache = new DNSCache(rndc, validator, cacheTTL);
|
||||||
};
|
|
||||||
|
|
||||||
async function getOrLoad(domain: string): Promise<CachedZone> {
|
async function getOrLoad(domain: string): Promise<CachedZone> {
|
||||||
if (!cache.has(domain)) {
|
if (!cache.has(domain)) {
|
||||||
if (!Object.values(authentic).includes(domain)) {
|
if (!keys.getKey(domain)) {
|
||||||
throw new Error('Invalid domain.');
|
throw new Error('Invalid domain.');
|
||||||
}
|
}
|
||||||
return cache.load(domain, path.resolve(dir, `${domain}.zone`));
|
return cache.load(domain, path.resolve(dir, `${domain}.zone`));
|
||||||
@ -51,7 +54,7 @@ api.use((req, res, next) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!parts[1] || !authentic[parts[1]]) {
|
if (!parts[1] || !keys.getDomain(parts[1])) {
|
||||||
res.status(401).json({ success: false, message: 'Unauthorized' });
|
res.status(401).json({ success: false, message: 'Unauthorized' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -66,7 +69,7 @@ const domainAuthorization: RequestHandler = (req, res, next) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (authentic[res.locals.token] !== req.params.domain) {
|
if (keys.getDomain(res.locals.token) !== req.params.domain) {
|
||||||
res.status(401).json({ success: false, message: 'Unauthorized access to domain' });
|
res.status(401).json({ success: false, message: 'Unauthorized access to domain' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -74,6 +77,9 @@ const domainAuthorization: RequestHandler = (req, res, next) => {
|
|||||||
next();
|
next();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get zone records
|
||||||
|
*/
|
||||||
api.get('/zone/records/:domain', domainAuthorization, async (req, res) => {
|
api.get('/zone/records/:domain', domainAuthorization, async (req, res) => {
|
||||||
const domain = req.params.domain;
|
const domain = req.params.domain;
|
||||||
const cached = await getOrLoad(domain);
|
const cached = await getOrLoad(domain);
|
||||||
@ -115,6 +121,15 @@ api.get('/zone/records/:domain', domainAuthorization, async (req, res) => {
|
|||||||
res.json(cached.zone.records);
|
res.json(cached.zone.records);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a record by its index in the zone file
|
||||||
|
* index: number;
|
||||||
|
* record: {
|
||||||
|
* name?: string;
|
||||||
|
* type?: DNSRecordType;
|
||||||
|
* value?: string;
|
||||||
|
* }
|
||||||
|
*/
|
||||||
api.post('/zone/records/:domain', domainAuthorization, async (req, res) => {
|
api.post('/zone/records/:domain', domainAuthorization, async (req, res) => {
|
||||||
const domain = req.params.domain;
|
const domain = req.params.domain;
|
||||||
const index = parseInt(req.body.index, 10);
|
const index = parseInt(req.body.index, 10);
|
||||||
@ -150,11 +165,19 @@ api.post('/zone/records/:domain', domainAuthorization, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!validator.validateRecord(record)) {
|
||||||
|
throw new Error('Validation error: Invalid characters');
|
||||||
|
}
|
||||||
|
|
||||||
await cache.update(domain, cached);
|
await cache.update(domain, cached);
|
||||||
|
|
||||||
res.json({ success: true, message: 'Record changed successfully.', record });
|
res.json({ success: true, message: 'Record changed successfully.', record });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a record from zone by its index in the zone file
|
||||||
|
* index: number;
|
||||||
|
*/
|
||||||
api.delete('/zone/records/:domain', domainAuthorization, async (req, res) => {
|
api.delete('/zone/records/:domain', domainAuthorization, async (req, res) => {
|
||||||
const domain = req.params.domain;
|
const domain = req.params.domain;
|
||||||
const index = parseInt(req.body.index, 10);
|
const index = parseInt(req.body.index, 10);
|
||||||
@ -176,6 +199,14 @@ api.delete('/zone/records/:domain', domainAuthorization, async (req, res) => {
|
|||||||
res.json({ success: true, message: 'Record deleted successfully.', record });
|
res.json({ success: true, message: 'Record deleted successfully.', record });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new record in zone
|
||||||
|
* record: {
|
||||||
|
* name: string;
|
||||||
|
* type: DNSRecordType;
|
||||||
|
* value: string;
|
||||||
|
* }
|
||||||
|
*/
|
||||||
api.put('/zone/records/:domain', domainAuthorization, async (req, res) => {
|
api.put('/zone/records/:domain', domainAuthorization, async (req, res) => {
|
||||||
const domain = req.params.domain;
|
const domain = req.params.domain;
|
||||||
const setter = req.body.record;
|
const setter = req.body.record;
|
||||||
@ -206,44 +237,68 @@ api.put('/zone/records/:domain', domainAuthorization, async (req, res) => {
|
|||||||
const { zone } = cached;
|
const { zone } = cached;
|
||||||
const newRecord = { name, type: upperType, value };
|
const newRecord = { name, type: upperType, value };
|
||||||
|
|
||||||
|
if (!validator.validateRecord(newRecord)) {
|
||||||
|
throw new Error('Validation error: Invalid characters');
|
||||||
|
}
|
||||||
|
|
||||||
zone.records.push(newRecord);
|
zone.records.push(newRecord);
|
||||||
|
|
||||||
await cache.update(domain, cached);
|
await cache.update(domain, cached);
|
||||||
res.status(201).json({ success: true, message: 'Record added.', record: newRecord });
|
res.status(201).json({ success: true, message: 'Record added.', record: newRecord });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get full zone as file
|
||||||
|
*/
|
||||||
api.get('/zone/:domain/download', domainAuthorization, async (req, res) => {
|
api.get('/zone/:domain/download', domainAuthorization, async (req, res) => {
|
||||||
const domain = req.params.domain;
|
const domain = req.params.domain;
|
||||||
const cached = await getOrLoad(domain);
|
const cached = await getOrLoad(domain);
|
||||||
res.send(createZoneFile(cached.zone).join('\n'));
|
res.send(createZoneFile(cached.zone).join('\n'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get full zone
|
||||||
|
*/
|
||||||
api.get('/zone/:domain', domainAuthorization, async (req, res) => {
|
api.get('/zone/:domain', domainAuthorization, async (req, res) => {
|
||||||
const domain = req.params.domain;
|
const domain = req.params.domain;
|
||||||
const cached = await getOrLoad(domain);
|
const cached = await getOrLoad(domain);
|
||||||
res.json(cached.zone);
|
res.json(cached.zone);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reload zone or change TTL
|
||||||
|
* ttl?: number
|
||||||
|
*/
|
||||||
api.post('/zone/:domain', domainAuthorization, async (req, res) => {
|
api.post('/zone/:domain', domainAuthorization, async (req, res) => {
|
||||||
const domain = req.params.domain;
|
const domain = req.params.domain;
|
||||||
const cached = await getOrLoad(domain);
|
const cached = await getOrLoad(domain);
|
||||||
|
|
||||||
if (!req.body.ttl) {
|
if (req.body.ttl) {
|
||||||
res.json({ success: true, message: 'Nothing was changed.' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const numTTL = parseInt(req.body.TTL, 10);
|
const numTTL = parseInt(req.body.TTL, 10);
|
||||||
if (!isNaN(numTTL)) {
|
if (isNaN(numTTL)) {
|
||||||
|
throw new Error('Invalid number for TTL');
|
||||||
|
}
|
||||||
cached.zone.ttl = numTTL;
|
cached.zone.ttl = numTTL;
|
||||||
}
|
}
|
||||||
|
|
||||||
await cache.update(domain, cached);
|
await cache.update(domain, cached);
|
||||||
|
|
||||||
|
if (req.body.ttl) {
|
||||||
res.json({ success: true, message: 'TTL changed successfully.', ttl: cached.zone.ttl });
|
res.json({ success: true, message: 'TTL changed successfully.', ttl: cached.zone.ttl });
|
||||||
|
} else {
|
||||||
|
res.json({ success: true, message: 'Zone reloaded successfully.' });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
api.post('/dyndns/:domain', domainAuthorization, async (req, res) => {
|
/**
|
||||||
|
* Quickly set IP address for the zone.
|
||||||
|
* Either for root or a subdomain of user choice.
|
||||||
|
* ipv4?: string;
|
||||||
|
* ipv6?: string;
|
||||||
|
* subdomain?: string;
|
||||||
|
* dualRequest?: boolean;
|
||||||
|
*/
|
||||||
|
api.post('/set-ip/:domain', domainAuthorization, async (req, res) => {
|
||||||
const domain = req.params.domain;
|
const domain = req.params.domain;
|
||||||
const subdomain = req.body.subdomain || '@';
|
const subdomain = req.body.subdomain || '@';
|
||||||
const waitPartial = req.body.dualRequest === true;
|
const waitPartial = req.body.dualRequest === true;
|
||||||
@ -334,4 +389,5 @@ const errorHandler: ErrorRequestHandler = (err: any, req: Request, res: Response
|
|||||||
api.use(errorHandler);
|
api.use(errorHandler);
|
||||||
app.use('/api/v1', api);
|
app.use('/api/v1', api);
|
||||||
|
|
||||||
|
keys.load().catch((e) => console.error(e.stack));
|
||||||
app.listen(port, () => console.log(`listening on ${port}`));
|
app.listen(port, () => console.log(`listening on ${port}`));
|
||||||
|
30
src/keys.ts
Normal file
30
src/keys.ts
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import * as fs from 'fs/promises';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
export class Keys {
|
||||||
|
private keys: Record<string, string> = {};
|
||||||
|
|
||||||
|
async load(): Promise<void> {
|
||||||
|
const file = path.join(__dirname, '..', 'keys.json');
|
||||||
|
let content = '{}';
|
||||||
|
try {
|
||||||
|
content = await fs.readFile(file, { encoding: 'utf-8' });
|
||||||
|
} catch (e) {
|
||||||
|
if (e.message.includes('ENOENT')) {
|
||||||
|
fs.writeFile(file, '{}');
|
||||||
|
} else {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.keys = JSON.parse(content);
|
||||||
|
}
|
||||||
|
|
||||||
|
getDomain(key: string): string | undefined {
|
||||||
|
return this.keys[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
getKey(domain: string): string | undefined {
|
||||||
|
return Object.keys(this.keys).find((key) => this.keys[key] === domain);
|
||||||
|
}
|
||||||
|
}
|
Reference in New Issue
Block a user