initial commit

This commit is contained in:
Evert Prants 2021-05-15 11:45:12 +03:00
commit 97d0a13165
Signed by: evert
GPG Key ID: 1688DA83D222D0B5
13 changed files with 1785 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
/node_modules/
*.zone

1049
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

21
package.json Normal file
View File

@ -0,0 +1,21 @@
{
"name": "icy-dyndns",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"express": "^4.17.1",
"express-async-errors": "^3.1.1"
},
"devDependencies": {
"@types/express": "^4.17.11",
"@types/node": "^15.3.0",
"typescript": "^4.2.4"
}
}

2
src/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
**/*.js
**/*.d.ts

72
src/dns/cache.ts Normal file
View File

@ -0,0 +1,72 @@
import { CachedZone, DNSRecord, DNSZone, SOARecord } from "../models/interfaces";
import { readZoneFile } from "./reader";
import { DNSRecordType } from "./records";
import { writeZoneFile } from "./writer";
export class DNSCache {
private cached: Record<string, CachedZone> = {};
constructor(private ttl = 1600) {}
has(name: string): boolean {
return this.cached[name] != null;
}
async get(name: string): Promise<CachedZone | null> {
const cached = this.cached[name];
if (!cached) {
return null;
}
if (cached.changed.getTime() < new Date().getTime() - this.ttl * 1000) {
return this.load(name, cached.file);
}
return this.cached[name];
}
async set(name: string, zone: CachedZone): Promise<void> {
this.cached[name] = zone;
}
async load(name: string, file: string): Promise<CachedZone> {
const zoneFile = await readZoneFile(file);
const cache = {
name,
file,
zone: zoneFile,
added: new Date(),
changed: new Date()
}
this.cached[name] = cache;
return cache;
}
async save(name: string): Promise<void> {
const zone = await this.get(name);
if (!zone) {
throw new Error('No such cached zone file!');
}
await writeZoneFile(zone.zone, zone.file);
}
async update(name: string, newZone?: CachedZone): Promise<void> {
let zone: CachedZone | null;
if (newZone) {
zone = newZone;
} else {
zone = await this.get(name);
}
if (!zone) {
throw new Error('No such cached zone file!');
}
zone.changed = new Date();
const soa = zone.zone.records.find((record) => record.type === DNSRecordType.SOA) as SOARecord;
soa.serial = Math.floor(Date.now() / 1000);
this.set(name, zone);
return this.save(name);
}
}

95
src/dns/reader.ts Normal file
View File

@ -0,0 +1,95 @@
import * as fs from 'fs/promises';
import { DNSRecord, DNSZone, SOARecord } from '../models/interfaces';
import { DNSRecordType } from './records';
function cleanString(str: string): string {
return str.replace(/;[^"]+$/, '') // Remove comments from the end
.replace(/\s+/g,' ') // Remove duplicate whitespace
.trim(); // Remove whitespace from start and end
}
function parseRecordLine(line: string, index: number, lines: string[]): DNSRecord | SOARecord | null {
let actualLine = '';
let clean = cleanString(line);
if (clean.includes('(')) {
actualLine += clean;
const trimLines = lines.slice(index + 1);
for (const trimLine of trimLines) {
actualLine += ' ' + cleanString(trimLine);
if (trimLine.includes(')')) {
break;
}
}
actualLine = cleanString(actualLine.replace(/\(|\)/g, ''));
} else {
actualLine = clean;
}
const split = actualLine.split(' ');
if (split[0] === 'IN' && split[1] === 'NS') {
return {
name: '',
type: DNSRecordType.NS,
value: split.slice(2).join(' '),
}
}
if (split[2] === 'SOA') {
return {
name: split[0],
type: DNSRecordType[split[2]],
value: split.slice(3).join(' '),
nameserver: split[3],
email: split[4],
serial: parseInt(split[5], 10),
refresh: parseInt(split[6], 10),
retry: parseInt(split[7], 10),
expire: parseInt(split[8], 10),
minimum: parseInt(split[9], 10),
};
}
if (!actualLine.includes('IN')) {
return null;
}
return {
name: split[0],
type: DNSRecordType[<keyof typeof DNSRecordType>split[2]],
value: split.slice(3).join(' ')
}
}
export function parseZoneFile(lines: string[]): DNSZone {
let ttl = 0;
const includes = [];
const records: DNSRecord[] = [];
for (const index in lines) {
const line = lines[index];
if (line.startsWith('$TTL')) {
ttl = parseInt(line.split(' ')[1], 10);
continue;
}
if (line.startsWith('$INCLUDE')) {
includes.push(...line.split(' ').slice(1));
continue;
}
const record = parseRecordLine(line, parseInt(index, 10), lines);
if (record) {
records.push(record);
}
}
return {
ttl, includes, records
}
}
export async function readZoneFile(file: string): Promise<DNSZone> {
const lines = await fs.readFile(file, { encoding: 'utf-8' });
const splitLines = lines.split('\n');
return parseZoneFile(splitLines);
}

9
src/dns/records.ts Normal file
View File

@ -0,0 +1,9 @@
export enum DNSRecordType {
A = 'A', AAAA = 'AAAA', AFSDB = 'AFSDB', APL = 'APL', CAA = 'CAA', CDNSKEY = 'CDNSKEY', CDS = 'CDS', CERT = 'CERT',
CNAME = 'CNAME', CSYNC = 'CSYNC', DHCID = 'DHCID', DLV = 'DLV', DNAME = 'DNAME', DNSKEY = 'DNSKEY', DS = 'DS',
EUI48 = 'EUI48', EUI64 = 'EUI64', HINFO = 'HINFO', IPSECKEY = 'IPSECKEY', KEY = 'KEY', KX = 'KX', LOC = 'LOC',
MX = 'MX', NAPTR = 'NAPTR', NS = 'NS', NSEC = 'NSEC', NSEC3 = 'NSEC3', NSEC3PARAM = 'NSEC3PARAM',
OPENPGPKEY = 'OPENPGPKEY', PTR = 'PTR', RRSIG = 'RRSIG', RP = 'RP', SIG = 'SIG', SMIMEA = 'SMIMEA', SOA = 'SOA',
SRV = 'SRV', SSHFP = 'SSHFP', TA = 'TA', TKEY = 'TKEY', TLSA = 'TLSA', TSIG = 'TSIG', TXT = 'TXT', URI = 'URI',
ZONEMD = 'ZONEMD', SVCB = 'SVCB', HTTPS = 'HTTPS', AXFR = 'AXFR', IXFR = 'IXFR', OPT = 'OPT', Wildcard = '*'
}

67
src/dns/writer.ts Normal file
View File

@ -0,0 +1,67 @@
import * as fs from 'fs/promises';
import { DNSZone, SOARecord } from '../models/interfaces';
import { DNSRecordType } from './records';
function createSOAString(record: SOARecord, padI: number, padJ: number): string[] {
const name = record.name.padEnd(padI, ' ');
const type = record.type.toString().padEnd(padJ, ' ');
const padK = ' '.padStart(
padI + 3
);
const padL = ['serial', 'refresh', 'retry', 'expire', 'minimum']
.reduce((previous, current) => {
const len = `${record[current]}`.length;
return previous > len ? previous : len;
}, 0) + 1;
return [
`${name} IN ${type} ${record.nameserver} ${record.email} (`,
`${padK} ${record.serial.toString().padEnd(padL, ' ')} ; Serial`,
`${padK} ${record.refresh.toString().padEnd(padL, ' ')} ; Refresh`,
`${padK} ${record.retry.toString().padEnd(padL, ' ')} ; Retry`,
`${padK} ${record.expire.toString().padEnd(padL, ' ')} ; Expire`,
`${padK} ${record.minimum.toString().padEnd(padL, ' ')} ; Minimum`,
`)`
];
}
export function createZoneFile(zone: DNSZone, preferredLineLength = 120): string[] {
const file: string[] = [];
file.push(`$TTL ${zone.ttl}`);
let longestName = 0;
let longestType = 0;
// First pass: for nice alignments
zone.records.forEach((record) => {
if (record.name.length > longestName) {
longestName = record.name.length;
}
if (record.type.toString().length > longestType) {
longestType = record.type.toString().length;
}
});
zone.records.forEach((record) => {
if (record.type === DNSRecordType.SOA) {
file.push(...createSOAString(record as SOARecord, longestName, longestType));
return;
}
const name = record.name.padEnd(longestName, ' ');
const type = record.type.toString().padEnd(longestType, ' ');
file.push(`${name} IN ${type} ${record.value}`);
});
zone.includes.forEach((include) => {
file.push(`$INCLUDE ${include}`);
});
return file;
}
export async function writeZoneFile(zone: DNSZone, file: string): Promise<string[]> {
const fullText = createZoneFile(zone);
await fs.writeFile(file, fullText.join('\n'));
return fullText;
}

272
src/index.ts Normal file
View File

@ -0,0 +1,272 @@
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}`));

51
src/ip/from-request.ts Normal file
View File

@ -0,0 +1,51 @@
import { Request } from "express";
import { validv4, validv6 } from "./validators";
export function fromRequest(req: Request): { v4: string | null, v6: string | null } {
let v4 = null;
const qv4 = req.query.ipv4 || req.body.ipv4;
let v6 = null;
const qv6 = req.query.ipv6 || req.body.ipv6;
// Lets begin our trials
// Determine Address from request headers
if (req.header('x-forwarded-for')) {
v4 = req.header('x-forwarded-for');
} else {
v4 = req.socket.remoteAddress;
}
if (v4 && !validv4(v4)) {
v6 = v4
v4 = null
}
// IPv4
if (qv4 && validv4(qv4)) {
v4 = qv4
}
if (qv4 === 'ignore') {
v4 = null
}
// IPv6
if (qv6 && validv6(qv6)) {
v6 = qv6
}
if (qv6 === 'ignore') {
v6 = null
}
if (v4 === null && v6 === null) {
return { v4, v6 };
}
// Remove subnet mask and prefix
if (v6) v6 = v6.replace(/\/(\d+)$/, '')
if (v4) v4 = v4.replace(/\/(\d+)$/, '')
return { v4, v6 };
}

42
src/ip/validators.ts Normal file
View File

@ -0,0 +1,42 @@
export function validv4(ipaddress: string): boolean {
if (/^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/.test(ipaddress)) {
return true
}
return false
}
export function validv6(value: string): boolean {
// See https://blogs.msdn.microsoft.com/oldnewthing/20060522-08/?p=31113 and
// https://4sysops.com/archives/ipv6-tutorial-part-4-ipv6-address-syntax/
const components = value.split(':')
if (components.length < 2 || components.length > 8) {
return false
}
if (components[0] !== '' || components[1] !== '') {
// Address does not begin with a zero compression ("::")
if (!components[0].match(/^[\da-f]{1,4}/i)) {
// Component must contain 1-4 hex characters
return false
}
}
let numberOfZeroCompressions = 0
for (let i = 1; i < components.length; ++i) {
if (components[i] === '') {
// We're inside a zero compression ("::")
++numberOfZeroCompressions
if (numberOfZeroCompressions > 1) {
// Zero compression can only occur once in an address
return false
}
continue
}
if (!components[i].match(/^[\da-f]{1,4}/i)) {
// Component must contain 1-4 hex characters
return false
}
}
return true
}

32
src/models/interfaces.ts Normal file
View File

@ -0,0 +1,32 @@
import { DNSRecordType } from "../dns/records";
export interface DNSRecord {
[key: string]: string | number;
name: string;
type: DNSRecordType;
value: string;
}
export interface SOARecord extends DNSRecord {
nameserver: string;
email: string;
serial: number;
refresh: number;
retry: number;
expire: number;
minimum: number;
}
export interface DNSZone {
ttl: number;
records: DNSRecord[];
includes: string[];
}
export interface CachedZone {
name: string;
file: string;
zone: DNSZone;
added: Date;
changed: Date;
}

71
tsconfig.json Normal file
View File

@ -0,0 +1,71 @@
{
"compilerOptions": {
/* Visit https://aka.ms/tsconfig.json to read more about this file */
/* Basic Options */
// "incremental": true, /* Enable incremental compilation */
"target": "es5", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */
"module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */
// "lib": [], /* Specify library files to be included in the compilation. */
// "allowJs": true, /* Allow javascript files to be compiled. */
// "checkJs": true, /* Report errors in .js files. */
// "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', 'react', 'react-jsx' or 'react-jsxdev'. */
// "declaration": true, /* Generates corresponding '.d.ts' file. */
// "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */
// "sourceMap": true, /* Generates corresponding '.map' file. */
// "outFile": "./", /* Concatenate and emit output to single file. */
// "outDir": "./", /* Redirect output structure to the directory. */
// "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */
// "composite": true, /* Enable project compilation */
// "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */
// "removeComments": true, /* Do not emit comments to output. */
// "noEmit": true, /* Do not emit outputs. */
// "importHelpers": true, /* Import emit helpers from 'tslib'. */
// "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */
// "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */
/* Strict Type-Checking Options */
"strict": true, /* Enable all strict type-checking options. */
// "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */
// "strictNullChecks": true, /* Enable strict null checks. */
// "strictFunctionTypes": true, /* Enable strict checking of function types. */
// "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */
// "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */
// "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */
// "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */
/* Additional Checks */
// "noUnusedLocals": true, /* Report errors on unused locals. */
// "noUnusedParameters": true, /* Report errors on unused parameters. */
// "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */
// "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */
// "noUncheckedIndexedAccess": true, /* Include 'undefined' in index signature results */
// "noPropertyAccessFromIndexSignature": true, /* Require undeclared properties from index signatures to use element accesses. */
/* Module Resolution Options */
// "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */
// "baseUrl": "./", /* Base directory to resolve non-absolute module names. */
// "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */
// "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */
// "typeRoots": [], /* List of folders to include type definitions from. */
// "types": [], /* Type declaration files to be included in compilation. */
// "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */
"esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */
// "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
/* Source Map Options */
// "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
// "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */
// "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */
/* Experimental Options */
// "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */
// "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */
/* Advanced Options */
"skipLibCheck": true, /* Skip type checking of declaration files. */
"forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */
}
}