Refactor some server-side string types into newtypes.

This commit is contained in:
baldo 2022-07-14 20:06:05 +02:00
parent 6c2bd85287
commit 720acfb276
10 changed files with 346 additions and 225 deletions

View file

@ -9,7 +9,7 @@ import {promises as fs} from "graceful-fs";
import {config} from "./config"; import {config} from "./config";
import type {CleartextPassword, PasswordHash, Username} from "./types"; import type {CleartextPassword, PasswordHash, Username} from "./types";
import {isString} from "./types"; import {isString, lift2, to} from "./types";
import Logger from "./logger"; import Logger from "./logger";
export const app: Express = express(); export const app: Express = express();
@ -17,7 +17,7 @@ export const app: Express = express();
/** /**
* Used to have some password comparison in case the user does not exist to avoid timing attacks. * Used to have some password comparison in case the user does not exist to avoid timing attacks.
*/ */
const INVALID_PASSWORD_HASH = "$2b$05$JebmV1q/ySuxa89GoJYlc.6SEnj1OZYBOfTf.TYAehcC5HLeJiWPi"; const INVALID_PASSWORD_HASH: PasswordHash = to("$2b$05$JebmV1q/ySuxa89GoJYlc.6SEnj1OZYBOfTf.TYAehcC5HLeJiWPi");
/** /**
* Trying to implement a timing safe string compare. * Trying to implement a timing safe string compare.
@ -50,15 +50,15 @@ async function isValidLogin(username: Username, password: CleartextPassword): Pr
// Iterate over all users every time to reduce risk of timing attacks. // Iterate over all users every time to reduce risk of timing attacks.
for (const userConfig of config.server.internal.users) { for (const userConfig of config.server.internal.users) {
if (timingSafeEqual(username, userConfig.username)) { if (lift2(timingSafeEqual)(username, userConfig.username)) {
passwordHash = userConfig.passwordHash; passwordHash = userConfig.passwordHash;
} }
} }
// Always compare some password even if the user does not exist to reduce risk of timing attacks. // Always compare some password even if the user does not exist to reduce risk of timing attacks.
const isValidPassword = await bcrypt.compare( const isValidPassword = await bcrypt.compare(
password, password.value,
passwordHash || INVALID_PASSWORD_HASH passwordHash?.value || INVALID_PASSWORD_HASH.value
); );
// Make sure password is only considered valid is user exists and therefor passwordHash is not undefined. // Make sure password is only considered valid is user exists and therefor passwordHash is not undefined.
@ -73,8 +73,8 @@ export function init(): void {
{ {
realm: 'Knotenformular - Intern' realm: 'Knotenformular - Intern'
}, },
function (username: Username, password: CleartextPassword, callback: BasicAuthCheckerCallback): void { function (username: string, password: string, callback: BasicAuthCheckerCallback): void {
isValidLogin(username, password) isValidLogin(to(username), to(password))
.then(result => callback(result)) .then(result => callback(result))
.catch(err => { .catch(err => {
Logger.tag('login').error(err); Logger.tag('login').error(err);

View file

@ -7,6 +7,7 @@ import * as Resources from "../utils/resources";
import {normalizeString} from "../utils/strings"; import {normalizeString} from "../utils/strings";
import {forConstraint} from "../validation/validator"; import {forConstraint} from "../validation/validator";
import {Request, Response} from "express"; import {Request, Response} from "express";
import {MonitoringToken, to} from "../types";
const isValidToken = forConstraint(CONSTRAINTS.token, false); const isValidToken = forConstraint(CONSTRAINTS.token, false);
@ -38,8 +39,9 @@ export function confirm(req: Request, res: Response): void {
if (!isValidToken(token)) { if (!isValidToken(token)) {
return Resources.error(res, {data: 'Invalid token.', type: ErrorTypes.badRequest}); return Resources.error(res, {data: 'Invalid token.', type: ErrorTypes.badRequest});
} }
const validatedToken: MonitoringToken = to(token);
MonitoringService.confirm(token) MonitoringService.confirm(validatedToken)
.then(node => Resources.success(res, { .then(node => Resources.success(res, {
hostname: node.hostname, hostname: node.hostname,
mac: node.mac, mac: node.mac,
@ -57,8 +59,9 @@ export function disable(req: Request, res: Response): void {
if (!isValidToken(token)) { if (!isValidToken(token)) {
return Resources.error(res, {data: 'Invalid token.', type: ErrorTypes.badRequest}); return Resources.error(res, {data: 'Invalid token.', type: ErrorTypes.badRequest});
} }
const validatedToken: MonitoringToken = to(token);
MonitoringService.disable(token) MonitoringService.disable(validatedToken)
.then(node => Resources.success(res, { .then(node => Resources.success(res, {
hostname: node.hostname, hostname: node.hostname,
mac: node.mac, mac: node.mac,

View file

@ -10,7 +10,7 @@ import {forConstraint, forConstraints} from "../validation/validator";
import * as Resources from "../utils/resources"; import * as Resources from "../utils/resources";
import {Entity} from "../utils/resources"; import {Entity} from "../utils/resources";
import {Request, Response} from "express"; import {Request, Response} from "express";
import {EnhancedNode, isNodeSortField, Node} from "../types"; import {EnhancedNode, isNodeSortField, MAC, Node, to, Token} from "../types";
const nodeFields = ['hostname', 'key', 'email', 'nickname', 'mac', 'coords', 'monitoring']; const nodeFields = ['hostname', 'key', 'email', 'nickname', 'mac', 'coords', 'monitoring'];
@ -49,13 +49,14 @@ export function update (req: Request, res: Response): void {
if (!isValidToken(token)) { if (!isValidToken(token)) {
return Resources.error(res, {data: 'Invalid token.', type: ErrorTypes.badRequest}); return Resources.error(res, {data: 'Invalid token.', type: ErrorTypes.badRequest});
} }
const validatedToken: Token = to(token);
const node = getNormalizedNodeData(data); const node = getNormalizedNodeData(data);
if (!isValidNode(node)) { if (!isValidNode(node)) {
return Resources.error(res, {data: 'Invalid node data.', type: ErrorTypes.badRequest}); return Resources.error(res, {data: 'Invalid node data.', type: ErrorTypes.badRequest});
} }
NodeService.updateNode(token, node) NodeService.updateNode(validatedToken, node)
.then(result => Resources.success(res, result)) .then(result => Resources.success(res, result))
.catch(err => Resources.error(res, err)); .catch(err => Resources.error(res, err));
} }
@ -67,8 +68,9 @@ export function remove(req: Request, res: Response): void {
if (!isValidToken(token)) { if (!isValidToken(token)) {
return Resources.error(res, {data: 'Invalid token.', type: ErrorTypes.badRequest}); return Resources.error(res, {data: 'Invalid token.', type: ErrorTypes.badRequest});
} }
const validatedToken: Token = to(token);
NodeService.deleteNode(token) NodeService.deleteNode(validatedToken)
.then(() => Resources.success(res, {})) .then(() => Resources.success(res, {}))
.catch(err => Resources.error(res, err)); .catch(err => Resources.error(res, err));
} }
@ -78,8 +80,9 @@ export function get(req: Request, res: Response): void {
if (!isValidToken(token)) { if (!isValidToken(token)) {
return Resources.error(res, {data: 'Invalid token.', type: ErrorTypes.badRequest}); return Resources.error(res, {data: 'Invalid token.', type: ErrorTypes.badRequest});
} }
const validatedToken: Token = to(token);
NodeService.getNodeDataByToken(token) NodeService.getNodeDataByToken(validatedToken)
.then(node => Resources.success(res, node)) .then(node => Resources.success(res, node))
.catch(err => Resources.error(res, err)); .catch(err => Resources.error(res, err));
} }
@ -94,11 +97,11 @@ async function doGetAll(req: Request): Promise<{ total: number; pageNodes: any }
!!node.token !!node.token
); );
const macs = _.map(realNodes, (node: Node): string => node.mac); const macs: MAC[] = _.map(realNodes, (node: Node): MAC => node.mac);
const nodeStateByMac = await MonitoringService.getByMacs(macs); const nodeStateByMac = await MonitoringService.getByMacs(macs);
const enhancedNodes: EnhancedNode[] = _.map(realNodes, (node: Node): EnhancedNode => { const enhancedNodes: EnhancedNode[] = _.map(realNodes, (node: Node): EnhancedNode => {
const nodeState = nodeStateByMac[node.mac]; const nodeState = nodeStateByMac[node.mac.value];
if (nodeState) { if (nodeState) {
return deepExtend({}, node, { return deepExtend({}, node, {
site: nodeState.site, site: nodeState.site,

View file

@ -5,7 +5,6 @@ import * as Resources from "../utils/resources";
import {Request, Response} from "express"; import {Request, Response} from "express";
export function get (req: Request, res: Response): void { export function get (req: Request, res: Response): void {
// TODO: Promises and types.
getNodeStatistics() getNodeStatistics()
.then(nodeStatistics => Resources.success( .then(nodeStatistics => Resources.success(
res, res,

View file

@ -1,6 +1,6 @@
import moment from 'moment'; import moment from 'moment';
import {ParsedNode, parseNode, parseNodesJson, parseTimestamp} from "./monitoringService"; import {ParsedNode, parseNode, parseNodesJson, parseTimestamp} from "./monitoringService";
import {OnlineState} from "../types"; import {MAC, OnlineState, to} from "../types";
import Logger from '../logger'; import Logger from '../logger';
import {MockLogger} from "../__mocks__/logger"; import {MockLogger} from "../__mocks__/logger";
@ -240,12 +240,12 @@ test('parseNode() should succeed parsing node without site and domain', () => {
// then // then
const expectedParsedNode: ParsedNode = { const expectedParsedNode: ParsedNode = {
mac: "12:34:56:78:90:AB", mac: to("12:34:56:78:90:AB"),
importTimestamp: importTimestamp, importTimestamp: importTimestamp,
state: OnlineState.ONLINE, state: OnlineState.ONLINE,
lastSeen: parseTimestamp(TIMESTAMP_VALID_STRING), lastSeen: parseTimestamp(TIMESTAMP_VALID_STRING),
site: '<unknown-site>', site: to("<unknown-site>"),
domain: '<unknown-domain>' domain: to("<unknown-domain>"),
}; };
expect(parseNode(importTimestamp, nodeData)).toEqual(expectedParsedNode); expect(parseNode(importTimestamp, nodeData)).toEqual(expectedParsedNode);
}); });
@ -272,12 +272,12 @@ test('parseNode() should succeed parsing node with site and domain', () => {
// then // then
const expectedParsedNode: ParsedNode = { const expectedParsedNode: ParsedNode = {
mac: "12:34:56:78:90:AB", mac: to("12:34:56:78:90:AB"),
importTimestamp: importTimestamp, importTimestamp: importTimestamp,
state: OnlineState.ONLINE, state: OnlineState.ONLINE,
lastSeen: parseTimestamp(TIMESTAMP_VALID_STRING), lastSeen: parseTimestamp(TIMESTAMP_VALID_STRING),
site: 'test-site', site: to("test-site"),
domain: 'test-domain' domain: to("test-domain")
}; };
expect(parseNode(importTimestamp, nodeData)).toEqual(expectedParsedNode); expect(parseNode(importTimestamp, nodeData)).toEqual(expectedParsedNode);
}); });
@ -461,12 +461,12 @@ test('parseNodesJson() should parse valid nodes', () => {
// then // then
const expectedParsedNode: ParsedNode = { const expectedParsedNode: ParsedNode = {
mac: "12:34:56:78:90:AB", mac: to("12:34:56:78:90:AB"),
importTimestamp: parseTimestamp(TIMESTAMP_VALID_STRING), importTimestamp: parseTimestamp(TIMESTAMP_VALID_STRING),
state: OnlineState.ONLINE, state: OnlineState.ONLINE,
lastSeen: parseTimestamp(TIMESTAMP_VALID_STRING), lastSeen: parseTimestamp(TIMESTAMP_VALID_STRING),
site: 'test-site', site: to("test-site"),
domain: 'test-domain' domain: to("test-domain"),
}; };
expect(result.importTimestamp.isValid()).toBe(true); expect(result.importTimestamp.isValid()).toBe(true);

View file

@ -17,14 +17,19 @@ import {monitoringDisableUrl} from "../utils/urlBuilder";
import CONSTRAINTS from "../validation/constraints"; import CONSTRAINTS from "../validation/constraints";
import {forConstraint} from "../validation/validator"; import {forConstraint} from "../validation/validator";
import { import {
Domain,
equal,
isMonitoringSortField, isMonitoringSortField,
MAC, MAC,
MailType, MailType,
MonitoringSortField, MonitoringSortField,
MonitoringToken,
Node, Node,
NodeId, NodeId,
NodeStateData, NodeStateData,
OnlineState, OnlineState,
Site,
to,
UnixTimestampSeconds UnixTimestampSeconds
} from "../types"; } from "../types";
@ -45,12 +50,12 @@ const DELETE_OFFLINE_NODES_AFTER_DURATION: { amount: number, unit: unitOfTime.Du
}; };
export type ParsedNode = { export type ParsedNode = {
mac: string, mac: MAC,
importTimestamp: Moment, importTimestamp: Moment,
state: OnlineState, state: OnlineState,
lastSeen: Moment, lastSeen: Moment,
site: string, site: Site,
domain: string, domain: Domain,
}; };
export type NodesParsingResult = { export type NodesParsingResult = {
@ -220,12 +225,12 @@ export function parseNode(importTimestamp: Moment, nodeData: any): ParsedNode {
} }
return { return {
mac: mac, mac: to(mac),
importTimestamp: importTimestamp, importTimestamp: importTimestamp,
state: isOnline ? OnlineState.ONLINE : OnlineState.OFFLINE, state: isOnline ? OnlineState.ONLINE : OnlineState.OFFLINE,
lastSeen: lastSeen, lastSeen: lastSeen,
site: site || '<unknown-site>', site: to(site || '<unknown-site>'), // FIXME: Handle this
domain: domain || '<unknown-domain>' domain: to(domain || '<unknown-domain>') // FIXME: Handle this
}; };
} }
@ -573,7 +578,7 @@ export async function getAll(restParams: RestParams): Promise<{ total: number, m
return {monitoringStates, total}; return {monitoringStates, total};
} }
export async function getByMacs(macs: MAC[]): Promise<{ [key: string]: NodeStateData }> { export async function getByMacs(macs: MAC[]): Promise<Record<string, NodeStateData>> {
if (_.isEmpty(macs)) { if (_.isEmpty(macs)) {
return {}; return {};
} }
@ -596,9 +601,9 @@ export async function getByMacs(macs: MAC[]): Promise<{ [key: string]: NodeState
return nodeStateByMac; return nodeStateByMac;
} }
export async function confirm(token: string): Promise<Node> { export async function confirm(token: MonitoringToken): Promise<Node> {
const {node, nodeSecrets} = await NodeService.getNodeDataWithSecretsByMonitoringToken(token); const {node, nodeSecrets} = await NodeService.getNodeDataWithSecretsByMonitoringToken(token);
if (!node.monitoring || !nodeSecrets.monitoringToken || nodeSecrets.monitoringToken !== token) { if (!node.monitoring || !nodeSecrets.monitoringToken || !equal(nodeSecrets.monitoringToken, token)) {
throw {data: 'Invalid token.', type: ErrorTypes.badRequest}; throw {data: 'Invalid token.', type: ErrorTypes.badRequest};
} }
@ -612,15 +617,15 @@ export async function confirm(token: string): Promise<Node> {
return newNode; return newNode;
} }
export async function disable(token: string): Promise<Node> { export async function disable(token: MonitoringToken): Promise<Node> {
const {node, nodeSecrets} = await NodeService.getNodeDataWithSecretsByMonitoringToken(token); const {node, nodeSecrets} = await NodeService.getNodeDataWithSecretsByMonitoringToken(token);
if (!node.monitoring || !nodeSecrets.monitoringToken || nodeSecrets.monitoringToken !== token) { if (!node.monitoring || !nodeSecrets.monitoringToken || !equal(nodeSecrets.monitoringToken, token)) {
throw {data: 'Invalid token.', type: ErrorTypes.badRequest}; throw {data: 'Invalid token.', type: ErrorTypes.badRequest};
} }
node.monitoring = false; node.monitoring = false;
node.monitoringConfirmed = false; node.monitoringConfirmed = false;
nodeSecrets.monitoringToken = ''; nodeSecrets.monitoringToken = undefined;
const {node: newNode} = await NodeService.internalUpdateNode(node.token, node, nodeSecrets); const {node: newNode} = await NodeService.internalUpdateNode(node.token, node, nodeSecrets);
return newNode; return newNode;

View file

@ -10,51 +10,69 @@ import Logger from "../logger";
import * as MailService from "../services/mailService"; import * as MailService from "../services/mailService";
import {normalizeString} from "../utils/strings"; import {normalizeString} from "../utils/strings";
import {monitoringConfirmUrl, monitoringDisableUrl} from "../utils/urlBuilder"; import {monitoringConfirmUrl, monitoringDisableUrl} from "../utils/urlBuilder";
import {FastdKey, MonitoringState, MonitoringToken, Node, NodeSecrets, NodeStatistics, UnixTimestampSeconds, Token} from "../types"; import {
FastdKey,
MAC,
MonitoringState,
MonitoringToken,
Node,
NodeSecrets,
NodeStatistics,
to,
Token,
unhandledEnumField,
UnixTimestampSeconds
} from "../types";
import util from "util"; import util from "util";
const pglob = util.promisify(glob); const pglob = util.promisify(glob);
type NodeFilter = { type NodeFilter = {
// TODO: Newtype
hostname?: string, hostname?: string,
mac?: string, mac?: MAC,
key?: string, key?: FastdKey,
token?: Token, token?: Token,
monitoringToken?: string, monitoringToken?: MonitoringToken,
} }
// TODO: Newtypes?
type NodeFilenameParsed = { type NodeFilenameParsed = {
hostname?: string, hostname?: string,
mac?: string, mac?: string,
key?: string, key?: string,
token?: Token, token?: string,
monitoringToken?: string, monitoringToken?: string,
} }
const linePrefixes = { enum LINE_PREFIX {
hostname: '# Knotenname: ', HOSTNAME = "# Knotenname: ",
nickname: '# Ansprechpartner: ', NICKNAME = "# Ansprechpartner: ",
email: '# Kontakt: ', EMAIL = "# Kontakt: ",
coords: '# Koordinaten: ', COORDS = "# Koordinaten: ",
mac: '# MAC: ', MAC = "# MAC: ",
token: '# Token: ', TOKEN = "# Token: ",
monitoring: '# Monitoring: ', MONITORING = "# Monitoring: ",
monitoringToken: '# Monitoring-Token: ' MONITORING_TOKEN = "# Monitoring-Token: ",
}; }
const filenameParts = ['hostname', 'mac', 'key', 'token', 'monitoringToken']; const filenameParts = ['hostname', 'mac', 'key', 'token', 'monitoringToken'];
function generateToken(): Token { function generateToken<Type extends { readonly __tag: symbol, value: any } =
return crypto.randomBytes(8).toString('hex'); { readonly __tag: unique symbol, value: never }>(): Type {
return to<Type>(crypto.randomBytes(8).toString('hex'));
} }
function toNodeFilesPattern(filter: NodeFilter): string { function toNodeFilesPattern(filter: NodeFilter): string {
const pattern = _.join( const fields: (string | undefined)[] = [
_.map( filter.hostname,
filenameParts, filter.mac?.value,
field => field in filter ? (filter as {[key: string]: string | undefined})[field] : '*'), filter.key?.value,
'@' filter.token?.value,
); filter.monitoringToken?.value,
];
const pattern = fields.map((value) => value || '*').join('@');
return config.server.peersPath + '/' + pattern.toLowerCase(); return config.server.peersPath + '/' + pattern.toLowerCase();
} }
@ -104,7 +122,7 @@ function isDuplicate(filter: NodeFilter, token: Token | null): boolean {
return true; return true;
} }
return parseNodeFilename(files[0]).token !== token; return parseNodeFilename(files[0]).token !== token.value;
} }
function checkNoDuplicates(token: Token | null, node: Node, nodeSecrets: NodeSecrets): void { function checkNoDuplicates(token: Token | null, node: Node, nodeSecrets: NodeSecrets): void {
@ -138,6 +156,34 @@ function toNodeFilename(token: Token, node: Node, nodeSecrets: NodeSecrets): str
).toLowerCase(); ).toLowerCase();
} }
function getNodeValue(prefix: LINE_PREFIX, node: Node, 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.value;
case LINE_PREFIX.TOKEN:
return node.token.value;
case LINE_PREFIX.MONITORING:
if (node.monitoring && node.monitoringConfirmed) {
return "aktiv";
} else if (node.monitoring && !node.monitoringConfirmed) {
return "pending";
}
return "";
case LINE_PREFIX.MONITORING_TOKEN:
return nodeSecrets.monitoringToken?.value || "";
default:
return unhandledEnumField(prefix);
}
}
async function writeNodeFile( async function writeNodeFile(
isUpdate: boolean, isUpdate: boolean,
token: Token, token: Token,
@ -146,35 +192,13 @@ async function writeNodeFile(
): Promise<{ token: Token, node: Node }> { ): Promise<{ token: Token, node: Node }> {
const filename = toNodeFilename(token, node, nodeSecrets); const filename = toNodeFilename(token, node, nodeSecrets);
let data = ''; let data = '';
_.each(linePrefixes, function (prefix, key) {
let value;
switch (key) {
case 'monitoring':
if (node.monitoring && node.monitoringConfirmed) {
value = 'aktiv';
} else if (node.monitoring && !node.monitoringConfirmed) {
value = 'pending';
} else {
value = '';
}
break;
case 'monitoringToken': for (const prefix of Object.values(LINE_PREFIX)) {
value = nodeSecrets.monitoringToken || ''; data += `${prefix}${getNodeValue(prefix, node, nodeSecrets)}\n`;
break; }
default:
value = key === 'token' ? token : (node as {[key: string]: any})[key];
if (_.isUndefined(value)) {
const nodeSecret = (nodeSecrets as {[key: string]: string})[key];
value = _.isUndefined(nodeSecret) ? '' : nodeSecret;
}
break;
}
data += prefix + value + '\n';
});
if (node.key) { if (node.key) {
data += 'key "' + node.key + '";\n'; data += `key "${node.key}";\n`;
} }
// since node.js is single threaded we don't need a lock // since node.js is single threaded we don't need a lock
@ -190,8 +214,7 @@ async function writeNodeFile(
const file = files[0]; const file = files[0];
try { try {
oldFs.unlinkSync(file); oldFs.unlinkSync(file);
} } catch (error) {
catch (error) {
Logger.tag('node', 'save').error('Could not delete old node file: ' + file, 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}; throw {data: 'Could not remove old node data.', type: ErrorTypes.internalError};
} }
@ -202,8 +225,7 @@ async function writeNodeFile(
try { try {
oldFs.writeFileSync(filename, data, 'utf8'); oldFs.writeFileSync(filename, data, 'utf8');
return {token, node}; return {token, node};
} } catch (error) {
catch (error) {
Logger.tag('node', 'save').error('Could not write node file: ' + filename, error); Logger.tag('node', 'save').error('Could not write node file: ' + filename, error);
throw {data: 'Could not write node data.', type: ErrorTypes.internalError}; throw {data: 'Could not write node data.', type: ErrorTypes.internalError};
} }
@ -213,8 +235,7 @@ async function deleteNodeFile(token: Token): Promise<void> {
let files; let files;
try { try {
files = await findNodeFiles({token: token}); files = await findNodeFiles({token: token});
} } catch (error) {
catch (error) {
Logger.tag('node', 'delete').error('Could not find node file: ' + files, error); Logger.tag('node', 'delete').error('Could not find node file: ' + files, error);
throw {data: 'Could not delete node.', type: ErrorTypes.internalError}; throw {data: 'Could not delete node.', type: ErrorTypes.internalError};
} }
@ -225,82 +246,109 @@ async function deleteNodeFile(token: Token): Promise<void> {
try { try {
oldFs.unlinkSync(files[0]); oldFs.unlinkSync(files[0]);
} } catch (error) {
catch (error) {
Logger.tag('node', 'delete').error('Could not delete node file: ' + files, error); Logger.tag('node', 'delete').error('Could not delete node file: ' + files, error);
throw {data: 'Could not delete node.', type: ErrorTypes.internalError}; throw {data: 'Could not delete node.', type: ErrorTypes.internalError};
} }
} }
class NodeBuilder {
public token: Token = to(""); // FIXME: Either make token optional in Node or handle this!
public nickname: string = "";
public email: string = "";
public hostname: string = ""; // FIXME: Either make hostname optional in Node or handle this!
public coords?: string;
public key?: FastdKey;
public mac: MAC = to(""); // FIXME: Either make mac optional in Node or handle this!
public monitoring: boolean = false;
public monitoringConfirmed: boolean = false;
public monitoringState: MonitoringState = MonitoringState.DISABLED;
constructor(
public readonly modifiedAt: UnixTimestampSeconds,
) {
}
public build(): Node {
return {
token: this.token,
nickname: this.nickname,
email: this.email,
hostname: this.hostname,
coords: this.coords,
key: this.key,
mac: this.mac,
monitoring: this.monitoring,
monitoringConfirmed: this.monitoringConfirmed,
monitoringState: this.monitoringState,
modifiedAt: this.modifiedAt,
}
}
}
function setNodeValue(prefix: LINE_PREFIX, node: NodeBuilder, nodeSecrets: NodeSecrets, value: string) {
switch (prefix) {
case LINE_PREFIX.HOSTNAME:
node.hostname = value;
break;
case LINE_PREFIX.NICKNAME:
node.nickname = value;
break;
case LINE_PREFIX.EMAIL:
node.email = value;
break;
case LINE_PREFIX.COORDS:
node.coords = value;
break;
case LINE_PREFIX.MAC:
node.mac = to(value);
break;
case LINE_PREFIX.TOKEN:
node.token = to(value);
break;
case LINE_PREFIX.MONITORING:
const active = value === 'aktiv';
const pending = value === 'pending';
node.monitoring = active || pending;
node.monitoringConfirmed = active;
node.monitoringState =
active ? MonitoringState.ACTIVE : (pending ? MonitoringState.PENDING : MonitoringState.DISABLED);
break;
case LINE_PREFIX.MONITORING_TOKEN:
nodeSecrets.monitoringToken = to<MonitoringToken>(value);
break;
default:
return unhandledEnumField(prefix);
}
}
async function parseNodeFile(file: string): Promise<{ node: Node, nodeSecrets: NodeSecrets }> { async function parseNodeFile(file: string): Promise<{ node: Node, nodeSecrets: NodeSecrets }> {
const contents = await fs.readFile(file); const contents = await fs.readFile(file);
const stats = await fs.lstat(file); const stats = await fs.lstat(file);
const modifiedAt = Math.floor(stats.mtimeMs / 1000); const modifiedAt = Math.floor(stats.mtimeMs / 1000);
const lines = contents.toString(); const lines = contents.toString().split("\n");
const node: {[key: string]: any} = {}; const node = new NodeBuilder(modifiedAt);
const nodeSecrets: {[key: string]: any} = {}; const nodeSecrets: NodeSecrets = {};
_.each(lines.split('\n'), function (line) { for (const line of lines) {
const entries: {[key: string]: string} = {}; if (line.substring(0, 5) === 'key "') {
node.key = to<FastdKey>(normalizeString(line.split('"')[1]));
for (const key of Object.keys(linePrefixes)) { } else {
const prefix = (linePrefixes as {[key: string]: string})[key]; for (const prefix of Object.values(LINE_PREFIX)) {
if (line.substring(0, prefix.length) === prefix) { if (line.substring(0, prefix.length) === prefix) {
entries[key] = normalizeString(line.substr(prefix.length)); const value = normalizeString(line.substring(prefix.length));
setNodeValue(prefix, node, nodeSecrets, value);
break; break;
} }
} }
if (_.isEmpty(entries) && line.substring(0, 5) === 'key "') {
entries.key = normalizeString(line.split('"')[1]);
} }
_.each(entries, function (value, key) {
switch (key) {
case 'mac':
node.mac = value;
node.mapId = _.toLower(value).replace(/:/g, '');
break;
case 'monitoring':
const active = value === 'aktiv';
const pending = value === 'pending';
node.monitoring = active || pending;
node.monitoringConfirmed = active;
node.monitoringState =
active ? MonitoringState.ACTIVE : (pending ? MonitoringState.PENDING : MonitoringState.DISABLED);
break;
case 'monitoringToken':
nodeSecrets.monitoringToken = value;
break;
default:
node[key] = value;
break;
} }
});
});
return { return {
node: { node: node.build(),
token: node.token as Token || '', nodeSecrets,
nickname: node.nickname as string || '',
email: node.email as string || '',
hostname: node.hostname as string || '',
coords: node.coords as string || undefined,
key: node.key as FastdKey || undefined,
mac: node.mac as string || '',
monitoring: !!node.monitoring,
monitoringConfirmed: !!node.monitoringConfirmed,
monitoringState: node.monitoringState as MonitoringState || MonitoringState.DISABLED,
modifiedAt,
},
nodeSecrets: {
monitoringToken: nodeSecrets.monitoringToken as MonitoringToken || undefined,
},
}; };
} }
@ -349,13 +397,13 @@ async function sendMonitoringConfirmationMail(node: Node, nodeSecrets: NodeSecre
} }
export async function createNode(node: Node): Promise<{ token: Token, node: Node }> { export async function createNode(node: Node): Promise<{ token: Token, node: Node }> {
const token = generateToken(); const token: Token = generateToken();
const nodeSecrets: NodeSecrets = {}; const nodeSecrets: NodeSecrets = {};
node.monitoringConfirmed = false; node.monitoringConfirmed = false;
if (node.monitoring) { if (node.monitoring) {
nodeSecrets.monitoringToken = generateToken(); nodeSecrets.monitoringToken = generateToken<MonitoringToken>();
} }
const written = await writeNodeFile(false, token, node, nodeSecrets); const written = await writeNodeFile(false, token, node, nodeSecrets);
@ -371,13 +419,13 @@ export async function updateNode (token: Token, node: Node): Promise<{token: Tok
const {node: currentNode, nodeSecrets} = await getNodeDataWithSecretsByToken(token); const {node: currentNode, nodeSecrets} = await getNodeDataWithSecretsByToken(token);
let monitoringConfirmed = false; let monitoringConfirmed = false;
let monitoringToken = ''; let monitoringToken: MonitoringToken | undefined;
if (node.monitoring) { if (node.monitoring) {
if (!currentNode.monitoring) { if (!currentNode.monitoring) {
// monitoring just has been enabled // monitoring just has been enabled
monitoringConfirmed = false; monitoringConfirmed = false;
monitoringToken = generateToken(); monitoringToken = generateToken<MonitoringToken>();
} else { } else {
// monitoring is still enabled // monitoring is still enabled
@ -385,12 +433,12 @@ export async function updateNode (token: Token, node: Node): Promise<{token: Tok
if (currentNode.email !== node.email) { if (currentNode.email !== node.email) {
// new email so we need a new token and a reconfirmation // new email so we need a new token and a reconfirmation
monitoringConfirmed = false; monitoringConfirmed = false;
monitoringToken = generateToken(); monitoringToken = generateToken<MonitoringToken>();
} else { } else {
// email unchanged, keep token (fix if not set) and confirmation state // email unchanged, keep token (fix if not set) and confirmation state
monitoringConfirmed = currentNode.monitoringConfirmed; monitoringConfirmed = currentNode.monitoringConfirmed;
monitoringToken = nodeSecrets.monitoringToken || generateToken(); monitoringToken = nodeSecrets.monitoringToken || generateToken<MonitoringToken>();
} }
} }
} }
@ -440,12 +488,12 @@ export async function getAllNodes(): Promise<Node[]> {
return nodes; return nodes;
} }
export async function getNodeDataWithSecretsByMac (mac: string): Promise<{node: Node, nodeSecrets: NodeSecrets} | null> { export async function getNodeDataWithSecretsByMac(mac: MAC): Promise<{ node: Node, nodeSecrets: NodeSecrets } | null> {
return await findNodeDataByFilePattern({ mac: mac }); return await findNodeDataByFilePattern({mac});
} }
export async function getNodeDataByMac (mac: string): Promise<Node | null> { export async function getNodeDataByMac(mac: MAC): Promise<Node | null> {
const result = await findNodeDataByFilePattern({ mac: mac }); const result = await findNodeDataByFilePattern({mac});
return result ? result.node : null; return result ? result.node : null;
} }
@ -481,8 +529,7 @@ export async function fixNodeFilenames(): Promise<void> {
if (file !== expectedFilename) { if (file !== expectedFilename) {
try { try {
await fs.rename(file, expectedFilename); await fs.rename(file, expectedFilename);
} } catch (error) {
catch (error) {
throw new Error( throw new Error(
'Cannot rename file ' + file + ' to ' + expectedFilename + ' => ' + error 'Cannot rename file ' + file + ' to ' + expectedFilename + ' => ' + error
); );
@ -518,10 +565,6 @@ export async function getNodeStatistics(): Promise<NodeStatistics> {
nodeStatistics.withCoords += 1; nodeStatistics.withCoords += 1;
} }
function ensureExhaustive(monitoringState: never): void {
throw new Error('Add missing case for monitoring stat below: ' + monitoringState);
}
const monitoringState = node.monitoringState; const monitoringState = node.monitoringState;
switch (monitoringState) { switch (monitoringState) {
case MonitoringState.ACTIVE: case MonitoringState.ACTIVE:
@ -535,7 +578,7 @@ export async function getNodeStatistics(): Promise<NodeStatistics> {
break; break;
default: default:
ensureExhaustive(monitoringState); unhandledEnumField(monitoringState);
} }
}); });

View file

@ -1,17 +1,32 @@
import {ArrayField, Field, RawJsonField} from "sparkson" import {ArrayField, Field, RawJsonField} from "sparkson"
import {ClientConfig} from "./shared"; import {ClientConfig, to} from "./shared";
// TODO: Replace string types by more specific types like URL, Password, etc. // TODO: Replace string types by more specific types like URL, Password, etc.
export type Username = string; export type Username = {
export type CleartextPassword = string; value: string;
export type PasswordHash = string; readonly __tag: unique symbol
};
export type CleartextPassword = {
value: string;
readonly __tag: unique symbol
};
export type PasswordHash = {
value: string;
readonly __tag: unique symbol
};
export class UsersConfig { export class UsersConfig {
public username: Username;
public passwordHash: PasswordHash;
constructor( constructor(
@Field("user") public username: Username, @Field("user") username: string,
@Field("passwordHash") public passwordHash: PasswordHash, @Field("passwordHash") passwordHash: string,
) {} ) {
this.username = to(username);
this.passwordHash = to(passwordHash);
}
} }
export class LoggingConfig { export class LoggingConfig {

View file

@ -6,10 +6,37 @@ export type TypeGuard<T> = (arg: unknown) => arg is T;
export type EnumValue<E> = E[keyof E]; export type EnumValue<E> = E[keyof E];
export type EnumTypeGuard<E> = TypeGuard<EnumValue<E>>; export type EnumTypeGuard<E> = TypeGuard<EnumValue<E>>;
export function unhandledEnumField(field: never): never {
throw new Error(`Unhandled enum field: ${field}`);
}
export function to<Type extends { readonly __tag: symbol, value: any } = { readonly __tag: unique symbol, value: never }>(value: Type['value']): Type {
return value as any as Type;
}
export function lift2<Result, Type extends { readonly __tag: symbol, value: any }>(callback: (a: Type["value"], b: Type["value"]) => Result): (newtype1: Type, newtype2: Type) => Result {
return (a, b) => callback(a.value, b.value);
}
export function equal<Result, Type extends { readonly __tag: symbol, value: any }>(a: Type, b: Type): boolean {
return lift2((a, b) => a === b)(a, b);
}
export function isObject(arg: unknown): arg is object { export function isObject(arg: unknown): arg is object {
return arg !== null && typeof arg === "object"; return arg !== null && typeof arg === "object";
} }
export function toIsNewtype<Type extends { readonly __tag: symbol, value: Value } = { readonly __tag: unique symbol, value: never }, Value = any>(isValue: TypeGuard<Value>): TypeGuard<Type> {
// TODO: Add validation pattern.
return (arg: unknown): arg is Type => {
if (!isObject(arg)) {
return false;
}
const newtype = arg as Type;
return isValue(newtype.value);
}
}
export function isArray<T>(arg: unknown, isT: TypeGuard<T>): arg is Array<T> { export function isArray<T>(arg: unknown, isT: TypeGuard<T>): arg is Array<T> {
if (!Array.isArray(arg)) { if (!Array.isArray(arg)) {
return false; return false;
@ -248,19 +275,32 @@ export function isClientConfig(arg: unknown): arg is ClientConfig {
} }
// TODO: Token type. // TODO: Token type.
export type Token = string; export type Token = {
export const isToken = isString; value: string;
readonly __tag: unique symbol
};
export const isToken = toIsNewtype<Token>(isString);
export type FastdKey = string; export type FastdKey = {
export const isFastdKey = isString; value: string;
readonly __tag: unique symbol
};
export const isFastdKey = toIsNewtype<FastdKey>(isString);
export type MAC = string; export type MAC = {
export const isMAC = isString; value: string;
readonly __tag: unique symbol
};
export const isMAC = toIsNewtype<MAC>(isString);
export type UnixTimestampSeconds = number; export type UnixTimestampSeconds = number;
export type UnixTimestampMilliseconds = number; export type UnixTimestampMilliseconds = number;
export type MonitoringToken = string; export type MonitoringToken = {
value: string;
readonly __tag: unique symbol
};
export enum MonitoringState { export enum MonitoringState {
ACTIVE = "active", ACTIVE = "active",
PENDING = "pending", PENDING = "pending",
@ -269,9 +309,12 @@ export enum MonitoringState {
export const isMonitoringState = toIsEnum(MonitoringState); export const isMonitoringState = toIsEnum(MonitoringState);
export type NodeId = string; export type NodeId = {
export const isNodeId = isString; value: string;
readonly __tag: unique symbol
};
// TODO: More Newtypes
export interface Node { export interface Node {
token: Token; token: Token;
nickname: string; nickname: string;
@ -310,13 +353,20 @@ export enum OnlineState {
ONLINE = "ONLINE", ONLINE = "ONLINE",
OFFLINE = "OFFLINE", OFFLINE = "OFFLINE",
} }
export const isOnlineState = toIsEnum(OnlineState); export const isOnlineState = toIsEnum(OnlineState);
export type Site = string; export type Site = {
export const isSite = isString; value: string;
readonly __tag: unique symbol
};
export const isSite = toIsNewtype<Site>(isString);
export type Domain = string; export type Domain = {
export const isDomain = isString; value: string;
readonly __tag: unique symbol
};
export const isDomain = toIsNewtype<Domain>(isString);
export interface EnhancedNode extends Node { export interface EnhancedNode extends Node {
site?: Site, site?: Site,
@ -426,7 +476,10 @@ export enum MailSortField {
export const isMailSortField = toIsEnum(MailSortField); export const isMailSortField = toIsEnum(MailSortField);
export type GenericSortField = string; export type GenericSortField = {
value: string;
readonly __tag: unique symbol
};
export enum SortDirection { export enum SortDirection {
ASCENDING = "ASC", ASCENDING = "ASC",

View file

@ -32,9 +32,9 @@ export function editNodeUrl (): string {
} }
export function monitoringConfirmUrl(monitoringToken: MonitoringToken): string { export function monitoringConfirmUrl(monitoringToken: MonitoringToken): string {
return formUrl('monitoring/confirm', { token: monitoringToken }); return formUrl('monitoring/confirm', {token: monitoringToken.value});
} }
export function monitoringDisableUrl(monitoringToken: MonitoringToken): string { export function monitoringDisableUrl(monitoringToken: MonitoringToken): string {
return formUrl('monitoring/disable', { token: monitoringToken }); return formUrl('monitoring/disable', {token: monitoringToken.value});
} }