diff --git a/.gitignore b/.gitignore index 98cdc73..e05a264 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ /keys.json *.zone /dist +/logs diff --git a/README.md b/README.md index 5174f02..45ce613 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,8 @@ This application is intended to be run behind a proxy. Requires node v14+ for `f - `PORT` - server port - `ZONEFILES` - path to zone files - `CACHE_TTL` - internal zone cache time-to-live +- `LOG_DIR` - Logs directory +- `LOG_FILES` - Log to files (boolean) - `RNDC_SERVER` - RNDC host - `RNDC_PORT` - RNDC port - `RNDC_KEYFILE` - location of RNDC's key file diff --git a/src/index.ts b/src/index.ts index 9f008b4..d9484f8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,6 +2,7 @@ import express, { ErrorRequestHandler, NextFunction, Request, RequestHandler, Re import cors from 'cors'; import 'express-async-errors'; import path from 'path'; +import fs from 'fs/promises'; import { DNSCache } from './dns/cache'; import { DNSRecordType } from './dns/records'; import { ReloadExecutor } from './dns/rndc'; @@ -10,6 +11,7 @@ import { createZoneFile } from './dns/writer'; import { fromRequest } from './ip/from-request'; import { Keys } from './keys'; import { CachedZone } from './models/interfaces'; +import { logger } from './log/Logger'; const port = parseInt(process.env.PORT || '9129', 10); const cacheTTL = parseInt(process.env.CACHE_TTL || '2629746', 10); @@ -223,6 +225,8 @@ api.patch('/zone/records/:domain', domainAuthorization, async (req, res) => { res.status(400).json({ success: false, message: 'Updating record(s) failed.', changed, errors }); } else if (changed.length) { res.json({ success: true, message: 'Record(s) changed successfully.', changed, errors }); + logger.info('zone %s changed records from %s', domain, req.ip); + logger.debug(changed); } else { res.json({ success: true, message: 'Nothing was changed.', changed, errors }); } @@ -275,6 +279,8 @@ api.delete('/zone/records/:domain', domainAuthorization, async (req, res) => { res.status(400).json({ success: false, message: 'Deleting record(s) failed.', deleted, errors }); } else if (deleted.length) { res.json({ success: true, message: 'Record(s) deleted successfully.', deleted, errors }); + logger.info('zone %s deleted records from %s', domain, req.ip); + logger.debug(deleted); } else { res.json({ success: true, message: 'Nothing was deleted.', deleted, errors }); } @@ -380,6 +386,8 @@ api.post('/zone/records/:domain', domainAuthorization, async (req, res) => { res.status(400).json({ success: false, message: 'Creating record(s) failed.', created, errors }); } else if (created.length) { res.status(201).json({ success: true, message: 'Record(s) created successfully.', created, errors }); + logger.info('zone %s created records from %s', domain, req.ip); + logger.debug(created); } else { res.json({ success: true, message: 'Nothing was created.', created, errors }); } @@ -423,8 +431,10 @@ api.post('/zone/:domain', domainAuthorization, async (req, res) => { if (req.body.ttl) { res.json({ success: true, message: 'TTL changed successfully.', ttl: cached.zone.ttl }); + logger.info('zone %s set ttl: %d from %s', domain, cached.zone.ttl, req.ip); } else { res.json({ success: true, message: 'Zone reloaded successfully.' }); + logger.info('zone %s reload from %s', domain, req.ip); } }); @@ -505,6 +515,7 @@ api.post('/set-ip/:domain', domainAuthorization, async (req, res) => { message: 'Waiting for next request..', actions }); + logger.info('set-ip (partial) from %s: %s', req.ip, actions.join('\n')); return; } @@ -515,6 +526,7 @@ api.post('/set-ip/:domain', domainAuthorization, async (req, res) => { message: 'Successfully updated zone file.', actions }); + logger.info('set-ip from %s: %s', req.ip, actions.join('\n')); }); const errorHandler: ErrorRequestHandler = (err: any, req: Request, res: Response, next: NextFunction) => { @@ -527,5 +539,18 @@ const errorHandler: ErrorRequestHandler = (err: any, req: Request, res: Response api.use(errorHandler); app.use('/api/v1', api); -keys.load().catch((e) => console.error(e.stack)); -app.listen(port, () => console.log(`listening on ${port}`)); +async function load() { + await keys.load(); + + if (logger.logToFile) { + try { + await fs.stat(logger.logDir); + } catch { + await fs.mkdir(logger.logDir); + } + } + + app.listen(port, () => logger.info(`listening on ${port}`)); +} + +load().catch((e) => console.error(e.stack)); diff --git a/src/log/Logger.ts b/src/log/Logger.ts new file mode 100644 index 0000000..7c5e90a --- /dev/null +++ b/src/log/Logger.ts @@ -0,0 +1,91 @@ +import { createWriteStream, WriteStream } from 'fs'; +import util from 'util'; +import path from 'path'; + +const p = (x: number) => x.toString().padStart(2, '0') + +export enum LogLevel { + Info = "INFO", + Warn = "WARN", + Error = "ERROR", + Debug = "DEBUG" +} + +export class Logger { + private fileName = ''; + private day = 0; + private stream?: WriteStream; + + constructor(public logDir: string, public logToFile = true) { + this.day = new Date().getDate(); + } + + static formatLogDate(date: Date): string { + return `${date.getFullYear()}-${p(date.getMonth() + 1)}-${p(date.getDate())}`; + } + + static formatLogTime(date: Date): string { + return `${p(date.getHours())}:${p(date.getMinutes())}:${p(date.getSeconds())}`; + } + + static formatLogDateTime(date: Date): string { + return Logger.formatLogDate(date) + ' ' + Logger.formatLogTime(date); + } + + static fromEnvironment(): Logger { + const logsPath = path.resolve(process.env.LOG_DIR || 'logs'); + const enableFileLog = process.env.LOG_FILES === "true" || true; + return new Logger(logsPath, enableFileLog); + } + + public log(level: LogLevel, message: any, ...fmt: any[]): void { + const input = util.format(message, ...fmt); + const composed = `[${level.toString().padStart(5)}] [${Logger.formatLogDateTime(new Date())}] ${input}`; + + if (level == LogLevel.Error) { + process.stderr.write(`${composed}\r\n`); + } else { + process.stdout.write(`${composed}\r\n`); + } + + if (this.logToFile) { + this.append(composed); + } + } + + public info(message: any, ...fmt: any[]): void { + this.log(LogLevel.Info, message, ...fmt); + } + + public error(message: any, ...fmt: any[]): void { + this.log(LogLevel.Error, message, ...fmt); + } + + public warn(message: any, ...fmt: any[]): void { + this.log(LogLevel.Warn, message, ...fmt); + } + + public debug(message: any, ...fmt: any[]): void { + this.log(LogLevel.Debug, message, ...fmt); + } + + private updateOutputFile(): void { + const date = new Date(); + if (this.day !== date.getDate() || !this.stream) { + if (this.stream) { + this.stream.close(); + } + + this.day = date.getDate(); + this.fileName = `icydns-${Logger.formatLogDate(date)}.log`; + this.stream = createWriteStream(path.join(this.logDir, this.fileName), { flags: 'a' }) + } + } + + private append(str: string): void { + this.updateOutputFile(); + this.stream?.write(`${str}\n`); + } +} + +export const logger = Logger.fromEnvironment();