ffffng/server/services/nodeService.ts

742 lines
21 KiB
TypeScript

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 "../shared/utils/strings";
import {
monitoringConfirmUrl,
monitoringDisableUrl,
} from "../utils/urlBuilder";
import {
BaseNode,
Coordinates,
CreateOrUpdateNode,
EmailAddress,
FastdKey,
Hostname,
isFastdKey,
isHostname,
isMAC,
isMonitoringToken,
isStoredNode,
isToken,
MAC,
MailType,
MonitoringState,
MonitoringToken,
Nickname,
NodeSecrets,
NodeStatistics,
StoredNode,
Token,
TypeGuard,
UnixTimestampMilliseconds,
UnixTimestampSeconds,
} from "../types";
import util from "util";
import { filterUndefinedFromJSON } from "../shared/utils/json";
import { unhandledEnumField } from "../shared/utils/enums";
import { toUnixTimestampSeconds } from "../shared/utils/time";
const pglob = util.promisify(glob);
type NodeFilter = {
hostname?: Hostname;
mac?: MAC;
key?: FastdKey;
token?: Token;
monitoringToken?: MonitoringToken;
};
type NodeFilenameParsed = {
hostname?: Hostname;
mac?: MAC;
key?: FastdKey;
token?: Token;
monitoringToken?: MonitoringToken;
};
enum LINE_PREFIX {
HOSTNAME = "# Knotenname: ",
NICKNAME = "# Ansprechpartner: ",
EMAIL = "# Kontakt: ",
COORDS = "# Koordinaten: ",
MAC = "# MAC: ",
TOKEN = "# Token: ",
MONITORING = "# Monitoring: ",
MONITORING_TOKEN = "# Monitoring-Token: ",
}
function generateToken<
Type extends string & { readonly __tag: symbol } = never
>(): 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<string[]> {
return pglob(toNodeFilesPattern(filter));
}
function findNodeFilesSync(filter: NodeFilter) {
return glob.sync(toNodeFilesPattern(filter));
}
async function findFilesInPeersPath(): Promise<string[]> {
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("@", 5);
function get<T>(isT: TypeGuard<T>, index: number): T | undefined {
const value =
index >= 0 && index < parts.length ? parts[index] : undefined;
return isT(value) ? value : undefined;
}
return {
hostname: get(isHostname, 0),
mac: get(isMAC, 1),
key: get(isFastdKey, 2),
token: get(isToken, 3),
monitoringToken: get(isMonitoringToken, 4),
};
}
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<StoredNode> {
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<void> {
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<UnixTimestampSeconds> {
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<void> {
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: filterUndefinedFromJSON(node),
confirmUrl: confirmUrl,
disableUrl: disableUrl,
}
);
}
export async function createNode(
node: CreateOrUpdateNode
): Promise<StoredNode> {
const token: Token = generateToken();
const nodeSecrets: NodeSecrets = {};
const monitoringState = node.monitoring
? MonitoringState.PENDING
: MonitoringState.DISABLED;
if (node.monitoring) {
nodeSecrets.monitoringToken = generateToken<MonitoringToken>();
}
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<StoredNode> {
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<MonitoringToken>();
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<MonitoringToken>();
} else {
// email unchanged, keep token (fix if not set) and confirmation state
monitoringState = currentNode.monitoringState;
monitoringToken =
nodeSecrets.monitoringToken ||
generateToken<MonitoringToken>();
}
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<StoredNode> {
return await writeNodeFile(true, token, node, monitoringState, nodeSecrets);
}
export async function deleteNode(token: Token): Promise<void> {
await deleteNodeFile(token);
}
export async function getAllNodes(): Promise<StoredNode[]> {
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<StoredNode | null> {
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<StoredNode> {
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<StoredNode> {
const { node } = await getNodeDataByFilePattern({
monitoringToken: monitoringToken,
});
return node;
}
export async function fixNodeFilenames(): Promise<void> {
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<StoredNode[]> {
const nodes = await getAllNodes();
return nodes.filter((node) => node.modifiedAt < timestamp);
}
export async function getNodeStatistics(): Promise<NodeStatistics> {
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;
}