273 lines
6.9 KiB
TypeScript
273 lines
6.9 KiB
TypeScript
|
import express, { ErrorRequestHandler, NextFunction, Request, Response } from 'express';
|
||
|
import 'express-async-errors';
|
||
|
import path from 'path';
|
||
|
import { DNSCache } from './dns/cache';
|
||
|
import { DNSRecordType } from './dns/records';
|
||
|
import { createZoneFile } from './dns/writer';
|
||
|
import { fromRequest } from './ip/from-request';
|
||
|
import { CachedZone, DNSRecord } from './models/interfaces';
|
||
|
|
||
|
const port = parseInt(process.env.PORT || '9129', 10);
|
||
|
const dir = process.env.ZONEFILES || '.';
|
||
|
|
||
|
const app = express();
|
||
|
const api = express.Router();
|
||
|
|
||
|
app.use(express.json());
|
||
|
|
||
|
const cache = new DNSCache();
|
||
|
const weHave = ['lunasqu.ee'];
|
||
|
|
||
|
async function getOrLoad(domain: string): Promise<CachedZone> {
|
||
|
if (!cache.has(domain)) {
|
||
|
if (!weHave.includes(domain)) {
|
||
|
throw new Error('Invalid domain.');
|
||
|
}
|
||
|
return cache.load(domain, path.resolve(dir, `${domain}.zone`));
|
||
|
}
|
||
|
|
||
|
const get = await cache.get(domain);
|
||
|
|
||
|
if (!get) {
|
||
|
throw new Error('Misconfigured domain zone file.');
|
||
|
}
|
||
|
|
||
|
return get;
|
||
|
}
|
||
|
|
||
|
api.get('/records/:domain/download', async (req, res) => {
|
||
|
const domain = req.params.domain;
|
||
|
const cached = await getOrLoad(domain);
|
||
|
res.send(createZoneFile(cached.zone).join('\n'));
|
||
|
});
|
||
|
|
||
|
api.get('/records/:domain', async (req, res) => {
|
||
|
const domain = req.params.domain;
|
||
|
const cached = await getOrLoad(domain);
|
||
|
|
||
|
const type = req.query.type;
|
||
|
const name = req.query.name;
|
||
|
const value = req.query.value;
|
||
|
|
||
|
if (type || name || value) {
|
||
|
const results = cached.zone.records.filter((zone) => {
|
||
|
if (type && zone.type !== type) {
|
||
|
return false;
|
||
|
}
|
||
|
|
||
|
if (name && zone.name !== name) {
|
||
|
return false;
|
||
|
}
|
||
|
|
||
|
if (value && !zone.value.includes(value as string)) {
|
||
|
return false;
|
||
|
}
|
||
|
|
||
|
return true;
|
||
|
}).map((record) => {
|
||
|
const inx = cached.zone.records.indexOf(record);
|
||
|
return {
|
||
|
...record,
|
||
|
index: inx
|
||
|
}
|
||
|
});
|
||
|
|
||
|
res.json({
|
||
|
records: results,
|
||
|
});
|
||
|
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
res.json(cached.zone);
|
||
|
});
|
||
|
|
||
|
api.post('/records/:domain', async (req, res) => {
|
||
|
const domain = req.params.domain;
|
||
|
const index = parseInt(req.body.index, 10);
|
||
|
const setters = req.body.record;
|
||
|
|
||
|
const cached = await getOrLoad(domain);
|
||
|
const { zone } = cached;
|
||
|
if (index == null || isNaN(index) || !zone.records[index]) {
|
||
|
throw new Error('Invalid record index.');
|
||
|
}
|
||
|
|
||
|
const keys = Object.keys(setters);
|
||
|
const record = zone.records[index];
|
||
|
if (!setters || keys.length === 0) {
|
||
|
res.json({ success: true, message: 'Nothing was changed.', record });
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
if (setters.type) {
|
||
|
const upperType = setters.type.toUpperCase();
|
||
|
if (upperType === 'SOA' && record.type !== DNSRecordType.SOA) {
|
||
|
throw new Error('Cannot change type to Start Of Authority.');
|
||
|
}
|
||
|
|
||
|
if (!DNSRecordType[<keyof typeof DNSRecordType>upperType] && upperType !== '*') {
|
||
|
throw new Error('Unsupported record type.');
|
||
|
}
|
||
|
}
|
||
|
|
||
|
keys.forEach((key) => {
|
||
|
if (record[key]) {
|
||
|
record[key] = setters[key];
|
||
|
}
|
||
|
});
|
||
|
|
||
|
await cache.update(domain, cached);
|
||
|
|
||
|
res.json({ success: true, message: 'Record changed successfully.', record });
|
||
|
});
|
||
|
|
||
|
api.delete('/records/:domain', async (req, res) => {
|
||
|
const domain = req.params.domain;
|
||
|
const index = parseInt(req.body.index, 10);
|
||
|
|
||
|
const cached = await getOrLoad(domain);
|
||
|
const { zone } = cached;
|
||
|
if (!index || isNaN(index) || !zone.records[index]) {
|
||
|
throw new Error('Invalid record index.');
|
||
|
}
|
||
|
|
||
|
const record = zone.records[index];
|
||
|
if (record.type === DNSRecordType.SOA) {
|
||
|
throw new Error('Cannot delete the Start Of Authority record.');
|
||
|
}
|
||
|
|
||
|
zone.records.splice(index, 1);
|
||
|
await cache.update(domain, cached);
|
||
|
|
||
|
res.json({ success: true, message: 'Record deleted successfully.', record });
|
||
|
});
|
||
|
|
||
|
api.put('/records/:domain', async (req, res) => {
|
||
|
const domain = req.params.domain;
|
||
|
const setter = req.body.record;
|
||
|
|
||
|
if (!setter) {
|
||
|
throw new Error('New record is missing!');
|
||
|
}
|
||
|
|
||
|
const missing = ['name', 'type', 'value'].reduce<string[]>(
|
||
|
(list, entry) => (setter[entry] == null ? [...list, entry] : list)
|
||
|
, []);
|
||
|
|
||
|
if (missing.length) {
|
||
|
throw new Error(`${missing.join(', ')} ${missing.length > 1 ? 'are' : 'is'} required.`);
|
||
|
}
|
||
|
|
||
|
const { name, type, value } = setter;
|
||
|
const upperType = type.toUpperCase();
|
||
|
if (upperType === 'SOA') {
|
||
|
throw new Error('Cannot add another Start Of Authority record. Please use POST method to modify the existing record.');
|
||
|
}
|
||
|
|
||
|
if (!DNSRecordType[<keyof typeof DNSRecordType>upperType] && upperType !== '*') {
|
||
|
throw new Error('Unsupported record type.');
|
||
|
}
|
||
|
|
||
|
const cached = await getOrLoad(domain);
|
||
|
const { zone } = cached;
|
||
|
const newRecord = { name, type: upperType, value };
|
||
|
zone.records.push(newRecord);
|
||
|
|
||
|
await cache.update(domain, cached);
|
||
|
res.status(201).json({ success: true, message: 'Record added.', record: newRecord });
|
||
|
});
|
||
|
|
||
|
api.post('/dyndns/:domain', async (req, res) => {
|
||
|
const domain = req.params.domain;
|
||
|
const subdomain = req.body.subdomain || '@';
|
||
|
const waitPartial = req.body.dualRequest === true;
|
||
|
const { v4, v6 } = fromRequest(req);
|
||
|
|
||
|
if (!v4 && !v6) {
|
||
|
res.json({ success: true, message: 'Nothing to do.' });
|
||
|
}
|
||
|
|
||
|
const cached = await getOrLoad(domain);
|
||
|
const { zone } = cached;
|
||
|
const actions: string[] = [];
|
||
|
|
||
|
if (v4) {
|
||
|
const findFirstA = zone.records.find((record) =>
|
||
|
record.type === DNSRecordType.A && record.name === subdomain
|
||
|
);
|
||
|
|
||
|
if (!findFirstA) {
|
||
|
zone.records.push({
|
||
|
name: subdomain,
|
||
|
type: DNSRecordType.A,
|
||
|
value: v4
|
||
|
});
|
||
|
actions.push(`created A record ${v4}`);
|
||
|
} else {
|
||
|
if (findFirstA.value !== v4) {
|
||
|
findFirstA.value = v4;
|
||
|
actions.push(`updated A record with ${v4}`);
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
if (v6) {
|
||
|
const findFirstAAAA = zone.records.find((record) =>
|
||
|
record.type === DNSRecordType.AAAA && record.name === subdomain
|
||
|
);
|
||
|
|
||
|
if (!findFirstAAAA) {
|
||
|
zone.records.push({
|
||
|
name: subdomain,
|
||
|
type: DNSRecordType.AAAA,
|
||
|
value: v6
|
||
|
});
|
||
|
actions.push(`created AAAA record ${v6}`);
|
||
|
} else {
|
||
|
if (findFirstAAAA.value !== v6) {
|
||
|
findFirstAAAA.value = v6;
|
||
|
actions.push(`updated AAAA record with ${v6}`);
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
if (!actions.length) {
|
||
|
res.json({
|
||
|
success: true,
|
||
|
message: 'Up to date.',
|
||
|
actions
|
||
|
});
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
if (waitPartial && ((v4 && !v6) || (!v4 && v6))) {
|
||
|
res.status(202).json({
|
||
|
success: true,
|
||
|
message: 'Waiting for next request..',
|
||
|
actions
|
||
|
});
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
await cache.update(domain, cached);
|
||
|
|
||
|
res.json({
|
||
|
success: true,
|
||
|
message: 'Successfully updated zone file.',
|
||
|
actions
|
||
|
});
|
||
|
});
|
||
|
|
||
|
const errorHandler: ErrorRequestHandler = (err: any, req: Request, res: Response, next: NextFunction) => {
|
||
|
res.status(400).json({
|
||
|
success: false,
|
||
|
message: err.message
|
||
|
});
|
||
|
}
|
||
|
|
||
|
api.use(errorHandler);
|
||
|
app.use('/api/v1', api);
|
||
|
|
||
|
app.listen(port, () => console.log(`listening on ${port}`));
|