import _ from "lodash"; import async from "async"; import crypto from "crypto"; import oldFs, {promises as fs} from "graceful-fs"; import glob from "glob"; import {config} from "../config"; import ErrorTypes from "../utils/errorTypes"; import Logger from "../logger"; import logger from "../logger"; import * as MailService from "../services/mailService"; import {normalizeString} from "../utils/strings"; import {monitoringConfirmUrl, monitoringDisableUrl} from "../utils/urlBuilder"; import { BaseNode, Coordinates, CreateOrUpdateNode, EmailAddress, FastdKey, Hostname, isStoredNode, MAC, MailType, MonitoringState, MonitoringToken, Nickname, NodeSecrets, NodeStatistics, StoredNode, Token, toUnixTimestampSeconds, unhandledEnumField, UnixTimestampMilliseconds, UnixTimestampSeconds } from "../types"; import util from "util"; const pglob = util.promisify(glob); type NodeFilter = { hostname?: Hostname, mac?: MAC, key?: FastdKey, token?: Token, monitoringToken?: MonitoringToken, } // TODO: Newtypes? type NodeFilenameParsed = { hostname?: string, mac?: string, key?: string, token?: string, monitoringToken?: string, } enum LINE_PREFIX { HOSTNAME = "# Knotenname: ", NICKNAME = "# Ansprechpartner: ", EMAIL = "# Kontakt: ", COORDS = "# Koordinaten: ", MAC = "# MAC: ", TOKEN = "# Token: ", MONITORING = "# Monitoring: ", MONITORING_TOKEN = "# Monitoring-Token: ", } const filenameParts = ['hostname', 'mac', 'key', 'token', 'monitoringToken']; function generateToken(): Type { return crypto.randomBytes(8).toString('hex') as Type; } function toNodeFilesPattern(filter: NodeFilter): string { const fields: (string | undefined)[] = [ filter.hostname, filter.mac, filter.key, filter.token, filter.monitoringToken, ]; const pattern = fields.map((value) => value || '*').join('@'); return config.server.peersPath + '/' + pattern.toLowerCase(); } function findNodeFiles(filter: NodeFilter): Promise { return pglob(toNodeFilesPattern(filter)); } function findNodeFilesSync(filter: NodeFilter) { return glob.sync(toNodeFilesPattern(filter)); } async function findFilesInPeersPath(): Promise { const files = await pglob(config.server.peersPath + '/*'); return await async.filter(files, (file, fileCallback) => { if (file[0] === '.') { return fileCallback(null, false); } fs.lstat(file) .then(stats => fileCallback(null, stats.isFile())) .catch(fileCallback); }); } function parseNodeFilename(filename: string): NodeFilenameParsed { const parts = filename.split('@', filenameParts.length); const parsed: { [key: string]: string | undefined } = {}; const zippedParts = _.zip(filenameParts, parts); for (const part of zippedParts) { const key = part[0]; if (key) { parsed[key] = part[1]; } } return parsed; } function isDuplicate(filter: NodeFilter, token?: Token): boolean { const files = findNodeFilesSync(filter); if (files.length === 0) { return false; } if (files.length > 1 || !token /* node is being created*/) { return true; } return parseNodeFilename(files[0]).token !== token; } function checkNoDuplicates(token: Token | undefined, node: BaseNode, nodeSecrets: NodeSecrets): void { if (isDuplicate({hostname: node.hostname}, token)) { throw {data: {msg: 'Already exists.', field: 'hostname'}, type: ErrorTypes.conflict}; } if (node.key) { if (isDuplicate({key: node.key}, token)) { throw {data: {msg: 'Already exists.', field: 'key'}, type: ErrorTypes.conflict}; } } if (isDuplicate({mac: node.mac}, token)) { throw {data: {msg: 'Already exists.', field: 'mac'}, type: ErrorTypes.conflict}; } if (nodeSecrets.monitoringToken && isDuplicate({monitoringToken: nodeSecrets.monitoringToken}, token)) { throw {data: {msg: 'Already exists.', field: 'monitoringToken'}, type: ErrorTypes.conflict}; } } function toNodeFilename(token: Token, node: BaseNode, nodeSecrets: NodeSecrets): string { return config.server.peersPath + '/' + ( (node.hostname || '') + '@' + (node.mac || '') + '@' + (node.key || '') + '@' + (token || '') + '@' + (nodeSecrets.monitoringToken || '') ).toLowerCase(); } function getNodeValue( prefix: LINE_PREFIX, token: Token, node: CreateOrUpdateNode, monitoringState: MonitoringState, nodeSecrets: NodeSecrets ): string { switch (prefix) { case LINE_PREFIX.HOSTNAME: return node.hostname; case LINE_PREFIX.NICKNAME: return node.nickname; case LINE_PREFIX.EMAIL: return node.email; case LINE_PREFIX.COORDS: return node.coords || ""; case LINE_PREFIX.MAC: return node.mac; case LINE_PREFIX.TOKEN: return token; case LINE_PREFIX.MONITORING: if (node.monitoring && monitoringState === MonitoringState.ACTIVE) { return "aktiv"; } else if (node.monitoring && monitoringState === MonitoringState.PENDING) { return "pending"; } return ""; case LINE_PREFIX.MONITORING_TOKEN: return nodeSecrets.monitoringToken || ""; default: return unhandledEnumField(prefix); } } async function writeNodeFile( isUpdate: boolean, token: Token, node: CreateOrUpdateNode, monitoringState: MonitoringState, nodeSecrets: NodeSecrets, ): Promise { const filename = toNodeFilename(token, node, nodeSecrets); let data = ''; for (const prefix of Object.values(LINE_PREFIX)) { data += `${prefix}${getNodeValue(prefix, token, node, monitoringState, nodeSecrets)}\n`; } if (node.key) { data += `key "${node.key}";\n`; } // since node.js is single threaded we don't need a lock when working with synchronous operations if (isUpdate) { const files = findNodeFilesSync({token: token}); if (files.length !== 1) { throw {data: 'Node not found.', type: ErrorTypes.notFound}; } checkNoDuplicates(token, node, nodeSecrets); const file = files[0]; try { oldFs.unlinkSync(file); } catch (error) { Logger.tag('node', 'save').error('Could not delete old node file: ' + file, error); throw {data: 'Could not remove old node data.', type: ErrorTypes.internalError}; } } else { checkNoDuplicates(undefined, node, nodeSecrets); } try { oldFs.writeFileSync(filename, data, 'utf8'); const {node: storedNode} = await parseNodeFile(filename); return storedNode; } catch (error) { Logger.tag('node', 'save').error('Could not write node file: ' + filename, error); throw {data: 'Could not write node data.', type: ErrorTypes.internalError}; } } async function deleteNodeFile(token: Token): Promise { let files; try { files = await findNodeFiles({token: token}); } catch (error) { Logger.tag('node', 'delete').error('Could not find node file: ' + files, error); throw {data: 'Could not delete node.', type: ErrorTypes.internalError}; } if (files.length !== 1) { throw {data: 'Node not found.', type: ErrorTypes.notFound}; } try { oldFs.unlinkSync(files[0]); } catch (error) { Logger.tag('node', 'delete').error('Could not delete node file: ' + files, error); throw {data: 'Could not delete node.', type: ErrorTypes.internalError}; } } class StoredNodeBuilder { public token: Token = "" as Token; // FIXME: Either make token optional in Node or handle this! public nickname: Nickname = "" as Nickname; public email: EmailAddress = "" as EmailAddress; public hostname: Hostname = "" as Hostname; // FIXME: Either make hostname optional in Node or handle this! public coords?: Coordinates; public key?: FastdKey; public mac: MAC = "" as MAC; // FIXME: Either make mac optional in Node or handle this! public monitoringState: MonitoringState = MonitoringState.DISABLED; constructor( public readonly modifiedAt: UnixTimestampSeconds, ) { } public build(): StoredNode { const node = { token: this.token, nickname: this.nickname, email: this.email, hostname: this.hostname, coords: this.coords, key: this.key, mac: this.mac, monitoringState: this.monitoringState, modifiedAt: this.modifiedAt, }; if (!isStoredNode(node)) { logger.tag("NodeService").error("Not a valid StoredNode:", node); throw {data: "Could not build StoredNode.", type: ErrorTypes.internalError}; } return node; } } function setNodeValue(prefix: LINE_PREFIX, node: StoredNodeBuilder, nodeSecrets: NodeSecrets, value: string) { switch (prefix) { case LINE_PREFIX.HOSTNAME: node.hostname = value as Hostname; break; case LINE_PREFIX.NICKNAME: node.nickname = value as Nickname; break; case LINE_PREFIX.EMAIL: node.email = value as EmailAddress; break; case LINE_PREFIX.COORDS: node.coords = value as Coordinates; break; case LINE_PREFIX.MAC: node.mac = value as MAC; break; case LINE_PREFIX.TOKEN: node.token = value as Token; break; case LINE_PREFIX.MONITORING: const active = value === 'aktiv'; const pending = value === 'pending'; node.monitoringState = active ? MonitoringState.ACTIVE : (pending ? MonitoringState.PENDING : MonitoringState.DISABLED); break; case LINE_PREFIX.MONITORING_TOKEN: nodeSecrets.monitoringToken = value as MonitoringToken; break; default: return unhandledEnumField(prefix); } } async function getModifiedAt(file: string): Promise { const modifiedAtMs = (await fs.lstat(file)).mtimeMs as UnixTimestampMilliseconds; return toUnixTimestampSeconds(modifiedAtMs); } async function parseNodeFile(file: string): Promise<{ node: StoredNode, nodeSecrets: NodeSecrets }> { const contents = await fs.readFile(file); const modifiedAt = await getModifiedAt(file); const lines = contents.toString().split("\n"); const node = new StoredNodeBuilder(modifiedAt); const nodeSecrets: NodeSecrets = {}; for (const line of lines) { if (line.substring(0, 5) === 'key "') { node.key = normalizeString(line.split('"')[1]) as FastdKey; } else { for (const prefix of Object.values(LINE_PREFIX)) { if (line.substring(0, prefix.length) === prefix) { const value = normalizeString(line.substring(prefix.length)); setNodeValue(prefix, node, nodeSecrets, value); break; } } } } return { node: node.build(), nodeSecrets, }; } async function findNodeDataByFilePattern(filter: NodeFilter): Promise<{ node: StoredNode, nodeSecrets: NodeSecrets } | null> { const files = await findNodeFiles(filter); if (files.length !== 1) { return null; } const file = files[0]; return await parseNodeFile(file); } async function getNodeDataByFilePattern(filter: NodeFilter): Promise<{ node: StoredNode, nodeSecrets: NodeSecrets }> { const result = await findNodeDataByFilePattern(filter); if (!result) { throw {data: 'Node not found.', type: ErrorTypes.notFound}; } return result; } async function sendMonitoringConfirmationMail(node: StoredNode, nodeSecrets: NodeSecrets): Promise { const monitoringToken = nodeSecrets.monitoringToken; if (!monitoringToken) { Logger .tag('monitoring', 'confirmation') .error('Could not enqueue confirmation mail. No monitoring token found.'); throw {data: 'Internal error.', type: ErrorTypes.internalError}; } const confirmUrl = monitoringConfirmUrl(monitoringToken); const disableUrl = monitoringDisableUrl(monitoringToken); await MailService.enqueue( config.server.email.from, node.nickname + ' <' + node.email + '>', MailType.MONITORING_CONFIRMATION, { node: node, confirmUrl: confirmUrl, disableUrl: disableUrl }, ); } export async function createNode(node: CreateOrUpdateNode): Promise { const token: Token = generateToken(); const nodeSecrets: NodeSecrets = {}; const monitoringState = node.monitoring ? MonitoringState.PENDING : MonitoringState.DISABLED; if (node.monitoring) { nodeSecrets.monitoringToken = generateToken(); } const createdNode = await writeNodeFile(false, token, node, monitoringState, nodeSecrets); if (createdNode.monitoringState == MonitoringState.PENDING) { await sendMonitoringConfirmationMail(createdNode, nodeSecrets); } return createdNode; } export async function updateNode(token: Token, node: CreateOrUpdateNode): Promise { const {node: currentNode, nodeSecrets} = await getNodeDataWithSecretsByToken(token); let monitoringState = MonitoringState.DISABLED; let monitoringToken: MonitoringToken | undefined = undefined; if (node.monitoring) { switch (currentNode.monitoringState) { case MonitoringState.DISABLED: // monitoring just has been enabled monitoringState = MonitoringState.PENDING; monitoringToken = generateToken(); break; case MonitoringState.PENDING: case MonitoringState.ACTIVE: if (currentNode.email !== node.email) { // new email so we need a new token and a reconfirmation monitoringState = MonitoringState.PENDING; monitoringToken = generateToken(); } else { // email unchanged, keep token (fix if not set) and confirmation state monitoringState = currentNode.monitoringState; monitoringToken = nodeSecrets.monitoringToken || generateToken(); } break; default: unhandledEnumField(currentNode.monitoringState); } } nodeSecrets.monitoringToken = monitoringToken; const storedNode = await writeNodeFile(true, token, node, monitoringState, nodeSecrets); if (storedNode.monitoringState === MonitoringState.PENDING) { await sendMonitoringConfirmationMail(storedNode, nodeSecrets) } return storedNode; } export async function internalUpdateNode( token: Token, node: CreateOrUpdateNode, monitoringState: MonitoringState, nodeSecrets: NodeSecrets, ): Promise { return await writeNodeFile(true, token, node, monitoringState, nodeSecrets); } export async function deleteNode(token: Token): Promise { await deleteNodeFile(token); } export async function getAllNodes(): Promise { let files; try { files = await findNodeFiles({}); } catch (error) { Logger.tag('nodes').error('Error getting all nodes:', error); throw {data: 'Internal error.', type: ErrorTypes.internalError}; } const nodes: StoredNode[] = []; for (const file of files) { try { const {node} = await parseNodeFile(file); nodes.push(node); } catch (error) { Logger.tag('nodes').error('Error getting all nodes:', error); throw {data: 'Internal error.', type: ErrorTypes.internalError}; } } return nodes; } export async function findNodeDataWithSecretsByMac(mac: MAC): Promise<{ node: StoredNode, nodeSecrets: NodeSecrets } | null> { return await findNodeDataByFilePattern({mac}); } export async function findNodeDataByMac(mac: MAC): Promise { const result = await findNodeDataByFilePattern({mac}); return result ? result.node : null; } export async function getNodeDataWithSecretsByToken(token: Token): Promise<{ node: StoredNode, nodeSecrets: NodeSecrets }> { return await getNodeDataByFilePattern({token: token}); } export async function getNodeDataByToken(token: Token): Promise { const {node} = await getNodeDataByFilePattern({token: token}); return node; } export async function getNodeDataWithSecretsByMonitoringToken( monitoringToken: MonitoringToken ): Promise<{ node: StoredNode, nodeSecrets: NodeSecrets }> { return await getNodeDataByFilePattern({monitoringToken: monitoringToken}); } export async function getNodeDataByMonitoringToken( monitoringToken: MonitoringToken ): Promise { const {node} = await getNodeDataByFilePattern({monitoringToken: monitoringToken}); return node; } export async function fixNodeFilenames(): Promise { const files = await findFilesInPeersPath(); for (const file of files) { const {node, nodeSecrets} = await parseNodeFile(file); const expectedFilename = toNodeFilename(node.token, node, nodeSecrets); if (file !== expectedFilename) { try { await fs.rename(file, expectedFilename); } catch (error) { throw new Error( 'Cannot rename file ' + file + ' to ' + expectedFilename + ' => ' + error ); } } } } export async function findNodesModifiedBefore(timestamp: UnixTimestampSeconds): Promise { const nodes = await getAllNodes(); return nodes.filter(node => node.modifiedAt < timestamp); } export async function getNodeStatistics(): Promise { const nodes = await getAllNodes(); const nodeStatistics: NodeStatistics = { registered: nodes.length, withVPN: 0, withCoords: 0, monitoring: { active: 0, pending: 0 } }; for (const node of nodes) { if (node.key) { nodeStatistics.withVPN += 1; } if (node.coords) { nodeStatistics.withCoords += 1; } const monitoringState = node.monitoringState; switch (monitoringState) { case MonitoringState.ACTIVE: nodeStatistics.monitoring.active += 1; break; case MonitoringState.PENDING: nodeStatistics.monitoring.pending += 1; break; case MonitoringState.DISABLED: // Not counted seperately. break; default: unhandledEnumField(monitoringState); } } return nodeStatistics; }