eerawlog
Version:
EmailEngine raw log viewer
210 lines (180 loc) • 7.98 kB
JavaScript
;
const pump = require('pump');
const split = require('split2');
const { Writable } = require('stream');
const clc = require('cli-color');
const argv = require('minimist')(process.argv.slice(2));
// --no-time hides the "[YYYY-MM-DD HH:MM:SS]" prefixes, --no-cid hides the
// bold connection header lines (useful for screenshots and documentation)
const showTime = argv.time !== false;
const showCid = argv.cid !== false;
const filter = (typeof argv.filter === 'object' && !Array.isArray(argv.filter) && argv.filter) || {};
const filterKeys = Object.keys(filter);
// Normalize each filter value (scalar or array) to an array once, so the
// per-line matcher does not re-allocate on every key of every log line.
const filterValues = {};
for (const key of filterKeys) {
filterValues[key] = [].concat(filter[key] || []);
}
const V1_PATH_RE = /^\/v1\//;
const CR_RE = /\r/g;
const TRAILING_LF_RE = /\n$/;
const LF_RE = /\n/g;
// C0 controls (except tab and LF), DEL, and C1 controls — the bytes that carry
// terminal escape/OSC sequences. CR is included so it can't reposition the cursor.
const CONTROL_RE = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g;
const GREY = clc.xterm(8);
const RED = clc.xterm(9);
const YELLOW = clc.xterm(11);
const MAGENTA = clc.xterm(13);
const SERVER = clc.xterm(39);
const CLIENT = clc.xterm(119);
function Parse(data) {
try {
return { err: null, value: JSON.parse(data) };
} catch (err) {
return { err, value: null, input: data };
}
}
// Render control bytes in a visible, inert form so untrusted log data — above all
// the base64-decoded raw IMAP traffic from a remote server — cannot smuggle terminal
// escape/OSC control sequences onto the operator's terminal. C0 codes become caret
// notation (ESC -> ^[); DEL and C1 codes become \xNN. Tab and LF are left intact.
function sanitize(text) {
return String(text).replace(CONTROL_RE, ch => {
const code = ch.charCodeAt(0);
return code < 0x20 ? '^' + String.fromCharCode(code + 0x40) : '\\x' + code.toString(16).padStart(2, '0');
});
}
function accountTag(account) {
return account ? ` [${sanitize(account)}]` : '';
}
// Strip CR, drop the trailing LF, neutralize control bytes, then re-indent
// continuation lines so they align under the prefix.
function padMultiline(text, indent) {
return sanitize(text.replace(CR_RE, '').replace(TRAILING_LF_RE, '')).replace(LF_RE, `\n${indent}`);
}
class Logger extends Writable {
constructor() {
super({ objectMode: true });
this.prevCid = null;
}
// Print a bold connection header the first time we see a new cid so
// multi-line raw dumps stay grouped under the connection they belong to.
_printConnHeader(value) {
if (!showCid || this.prevCid === value.cid) {
return;
}
this.prevCid = value.cid;
console.log(clc.bold(`${sanitize(value.cid)}${accountTag(value.account)}`));
}
_write(chunk, encoding, callback) {
try {
if (chunk.err && chunk.input) {
console.log(YELLOW(sanitize(chunk.input)));
}
if (!chunk.value) {
return;
}
const value = chunk.value;
for (const key of filterKeys) {
let hasMatch = false;
for (let val of filterValues[key]) {
if (typeof value[key] === 'number' && !isNaN(val)) {
val = Number(val);
} else if (typeof value[key] === 'string') {
val = val.toString();
}
if (value[key] === val) {
hasMatch = true;
} else if (key === 'account' && value.component === 'api' && value.req?.url?.includes(`/${val}/`)) {
hasMatch = true;
value.account = value.account || val;
}
}
if (!hasMatch) {
return;
}
}
const time = new Date(value.time || Date.now()).toISOString().slice(0, 19).replace('T', ' ');
const timePrefix = showTime ? `[${time}] ` : '';
if (value.action === 'onPreHandler' && V1_PATH_RE.test(value.path)) {
console.log(`${GREY(`${timePrefix}H:`)} ${MAGENTA(`${value.method.toUpperCase()} ${sanitize(value.path)}${accountTag(value.account)}`)}`);
}
if (value.component === 'api' && value.req && value.res && value.msg) {
console.log(
YELLOW(
`${timePrefix}API${clc.bold(accountTag(value.account))}: ${value.req.method.toUpperCase()} ${sanitize(value.req.url)} ${value.res.statusCode} (${value.responseTime}ms)`
)
);
}
if (value.action === 'renewAccessToken') {
const prefix = timePrefix;
const indent = ' '.repeat(prefix.length);
const color = value.error ? RED : YELLOW;
const pad = text => color(indent + padMultiline(text, indent));
console.log(
color(
`${prefix}OAuth2${clc.bold(accountTag(value.account))}: ${sanitize(value.msg)}${value.flag?.message ? `: ${sanitize(value.flag.message)}` : ''}`
)
);
if (value.expires) {
console.log(pad(`Expires on ${value.expires}`));
}
if (value.scopes) {
console.log(pad('Provisioned scopes:\n- ' + value.scopes.join('\n- ')));
}
if (value.response?.error) {
console.log(
pad(
Object.keys(value.response)
.map(k => `${k}: ${value.response[k]}`)
.join('\n')
)
);
}
}
if (value.src === 'connection' && value.host && value.port) {
this._printConnHeader(value);
console.log(
GREY(
`${timePrefix}Connection established to ${sanitize(value.host)}:${value.port} (${
value.secure
? `${sanitize(value.version)} / ${sanitize(value.algo)}, ${value.authorized ? 'valid' : 'invalid'} certificate`
: 'no tls'
})`
)
);
}
if (value.src === 'tls' && value.algo && value.version) {
this._printConnHeader(value);
console.log(
GREY(
`${timePrefix}TLS Session established using ${sanitize(value.version)} / ${sanitize(value.algo)}, ${
value.authorized ? 'valid' : 'invalid'
} certificate`
)
);
}
if (value.src && value.data) {
const prefix = `${timePrefix}${sanitize(value.src).toUpperCase().trim()}: [${value.secure ? 'S' : ' '}${value.compress ? 'C' : ' '}] `;
const indent = ' '.repeat(prefix.length);
const color = value.src === 's' ? SERVER : CLIENT;
this._printConnHeader(value);
console.log(`${GREY(prefix)}${color(padMultiline(Buffer.from(value.data, 'base64').toString(), indent))}`);
}
} catch (err) {
console.error(err);
} finally {
callback();
}
}
_final(callback) {
console.log(GREY('input stream ended'));
callback();
}
}
pump(process.stdin, split(Parse), new Logger());
process.on('SIGINT', function () {
process.exit(0);
});