diff --git a/coreos.txt b/coreos.txt new file mode 100644 index 000000000..e9d14d5ee --- /dev/null +++ b/coreos.txt @@ -0,0 +1,52 @@ +-----BEGIN RSA PRIVATE KEY----- +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 +-----END RSA PRIVATE KEY----- + diff --git a/key.txt b/key.txt new file mode 100644 index 000000000..c1936468f --- /dev/null +++ b/key.txt @@ -0,0 +1,51 @@ +-----BEGIN RSA PRIVATE KEY----- +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 +-----END RSA PRIVATE KEY----- diff --git a/src/shadowbox/infrastructure/json_config.ts b/src/shadowbox/infrastructure/json_config.ts new file mode 100644 index 000000000..fd19c4a6f --- /dev/null +++ b/src/shadowbox/infrastructure/json_config.ts @@ -0,0 +1,112 @@ +// Copyright 2018 The Outline Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import * as fs from 'fs'; + +import * as file_read from './file_read'; +import * as logging from './logging'; + +export interface JsonConfig { + // Returns a reference (*not* a copy) to the json object backing the config. + data(): T; + // Writes the config to the backing storage. + write(); +} + +export function loadFileConfig(filename: string): JsonConfig { + const text = file_read.readFileIfExists(filename); + let dataJson = {} as T; + if (text) { + dataJson = JSON.parse(text) as T; + } + return new FileConfig(filename, dataJson); +} + +// FileConfig is a JsonConfig backed by a filesystem file. +export class FileConfig implements JsonConfig { + constructor(private filename: string, private dataJson: T) {} + + data(): T { + return this.dataJson; + } + + write() { + // Write to temporary file, then move that temporary file to the + // persistent location, to avoid accidentally breaking the metrics file. + // Use *Sync calls for atomic operations, to guard against corrupting + // these files. + const tempFilename = `${this.filename}.${Date.now()}`; + try { + fs.writeFileSync(tempFilename, JSON.stringify(this.dataJson), {encoding: 'utf8'}); + fs.renameSync(tempFilename, this.filename); + } catch (error) { + // TODO: Stop swalling the exception and handle it in the callers. + logging.error(`Error writing config ${this.filename} ${error}`); + } + } +} + +// ChildConfig is a JsonConfig backed by another config. +export class ChildConfig implements JsonConfig { + constructor(private parentConfig: JsonConfig<{}>, private dataJson: T) {} + + data(): T { + return this.dataJson; + } + + write() { + this.parentConfig.write(); + } +} + +// DelayedConfig is a JsonConfig that only writes the data in a periodic time interval. +// Calls to write() will mark the data as "dirty" for the next inverval. +export class DelayedConfig implements JsonConfig { + private dirty = false; + constructor(private config: JsonConfig, writePeriodMs: number) { + // This repeated call will never be cancelled until the execution is terminated. + setInterval(() => { + if (!this.dirty) { + return; + } + this.config.write(); + this.dirty = false; + }, writePeriodMs); + } + + data(): T { + return this.config.data(); + } + + write() { + this.dirty = true; + } +} + +// InMemoryConfig is a JsonConfig backed by an internal member variable. Useful for testing. +export class InMemoryConfig implements JsonConfig { + // Holds the data JSON as it was when `write()` was called. + public mostRecentWrite: T; + constructor(private dataJson: T) { + this.mostRecentWrite = this.dataJson; + } + + data(): T { + return this.dataJson; + } + + write() { + this.mostRecentWrite = JSON.parse(JSON.stringify(this.dataJson)); + } +} diff --git a/src/shadowbox/model/metrics.ts b/src/shadowbox/model/metrics.ts index e74c60258..e572811ef 100644 --- a/src/shadowbox/model/metrics.ts +++ b/src/shadowbox/model/metrics.ts @@ -15,34 +15,15 @@ import {AccessKeyId} from './access_key'; export type LastHourMetricsReadyCallback = - (startDatetime: Date, endDatetime: Date, lastHourUserStats: Map) => - void; + (startDatetime: Date, endDatetime: Date, + lastHourUserMetrics: Map) => void; -// TODO: replace "user" with "access key" in metrics. This may also require changing -// - the metrics server -// - the metrics bigquery tables -// - the persisted metrics format (JSON file). -export interface Stats { - // Record the number of bytes transferred for a user, and include known - // client IP addresses that are connected for that user. If there are >1 - // IP addresses, numBytes is the sum of bytes transferred across all of those - // clients - we do not know the breakdown of how many bytes were transferred - // per IP address, due to limitations of the ss-server. ipAddresses are only - // used for recording which countries clients are connecting from. - recordBytesTransferred( - userId: AccessKeyId, metricsUserId: AccessKeyId, numBytes: number, ipAddresses: string[]); - // Get 30 day data usage, broken down by userId. - get30DayByteTransfer(): DataUsageByUser; - // Register callback for hourly metrics report. - onLastHourMetricsReady(callback: LastHourMetricsReadyCallback): void; -} - -export interface PerUserStats { +export interface PerUserMetrics { bytesTransferred: number; anonymizedIpAddresses: Set; } -// Byte transfer stats for the past 30 days, including both inbound and outbound. +// Byte transfer metrics for the past 30 days, including both inbound and outbound. // TODO: this is copied at src/model/server.ts. Both copies should // be kept in sync, until we can find a way to share code between the web_app // and shadowbox. diff --git a/src/shadowbox/model/shadowsocks_server.ts b/src/shadowbox/model/shadowsocks_server.ts index c30e99e34..f2fdfe044 100644 --- a/src/shadowbox/model/shadowsocks_server.ts +++ b/src/shadowbox/model/shadowsocks_server.ts @@ -16,7 +16,7 @@ import * as dgram from 'dgram'; export interface ShadowsocksServer { startInstance( - portNumber: number, password: string, statsSocket: dgram.Socket, + portNumber: number, password: string, metricsSocket: dgram.Socket, encryptionMethod?: string): Promise; } diff --git a/src/shadowbox/server/libev_shadowsocks_server.ts b/src/shadowbox/server/libev_shadowsocks_server.ts index c16be2488..a983289c5 100644 --- a/src/shadowbox/server/libev_shadowsocks_server.ts +++ b/src/shadowbox/server/libev_shadowsocks_server.ts @@ -30,17 +30,17 @@ export class LibevShadowsocksServer implements ShadowsocksServer { constructor(private publicAddress: string, private verbose: boolean) {} public startInstance( - portNumber: number, password: string, statsSocket: dgram.Socket, + portNumber: number, password: string, metricsSocket: dgram.Socket, encryptionMethod = this.DEFAULT_METHOD): Promise { logging.info(`Starting server on port ${portNumber}`); - const statsAddress = statsSocket.address(); + const metricsAddress = metricsSocket.address(); const commandArguments = [ '-m', encryptionMethod, // Encryption method '-u', // Allow UDP '--fast-open', // Allow TCP fast open '-p', portNumber.toString(), '-k', password, '--manager-address', - `${statsAddress.address}:${statsAddress.port}` + `${metricsAddress.address}:${metricsAddress.port}` ]; logging.info('starting ss-server with args: ' + commandArguments.join(' ')); // Add the system DNS servers. @@ -77,7 +77,7 @@ export class LibevShadowsocksServer implements ShadowsocksServer { })); return Promise.resolve(new LibevShadowsocksServerInstance( - childProcess, portNumber, password, encryptionMethod, accessUrl, statsSocket)); + childProcess, portNumber, password, encryptionMethod, accessUrl, metricsSocket)); } } @@ -88,7 +88,7 @@ class LibevShadowsocksServerInstance implements ShadowsocksInstance { constructor( private childProcess: child_process.ChildProcess, public portNumber: number, public password, public encryptionMethod: string, public accessUrl: string, - private statsSocket: dgram.Socket) {} + private metricsSocket: dgram.Socket) {} public stop() { logging.info(`Stopping server on port ${this.portNumber}`); @@ -106,30 +106,30 @@ class LibevShadowsocksServerInstance implements ShadowsocksInstance { // https://github.com/shadowsocks/shadowsocks-libev/blob/a16826b83e73af386806d1b51149f8321820835e/src/server.c#L172 public onInboundBytes(callback: (bytes: number, ipAddresses: string[]) => void) { if (this.eventEmitter.listenerCount(this.INBOUND_BYTES_EVENT) === 0) { - this.createStatsListener(); + this.createMetricsListener(); } this.eventEmitter.on(this.INBOUND_BYTES_EVENT, callback); } - private createStatsListener() { + private createMetricsListener() { let lastInboundBytes = 0; - this.statsSocket.on('message', (buf: Buffer) => { - let statsMessage; + this.metricsSocket.on('message', (buf: Buffer) => { + let metricsMessage; try { - statsMessage = parseStatsMessage(buf); + metricsMessage = parseMetricsMessage(buf); } catch (err) { - logging.error('error parsing stats: ' + buf + ', ' + err); + logging.error('error parsing metrics: ' + buf + ', ' + err); return; } - if (statsMessage.portNumber !== this.portNumber) { - // Ignore stats for other ss-servers, which post to the same statsSocket. + if (metricsMessage.portNumber !== this.portNumber) { + // Ignore metrics for other ss-servers, which post to the same metricsSocket. return; } - const delta = statsMessage.totalInboundBytes - lastInboundBytes; + const delta = metricsMessage.totalInboundBytes - lastInboundBytes; if (delta > 0) { this.getConnectedClientIPAddresses() .then((ipAddresses: string[]) => { - lastInboundBytes = statsMessage.totalInboundBytes; + lastInboundBytes = metricsMessage.totalInboundBytes; this.eventEmitter.emit(this.INBOUND_BYTES_EVENT, delta, ipAddresses); }) .catch((err) => { @@ -165,12 +165,12 @@ class LibevShadowsocksServerInstance implements ShadowsocksInstance { } } -interface StatsMessage { +interface MetricsMessage { portNumber: number; totalInboundBytes: number; } -function parseStatsMessage(buf): StatsMessage { +function parseMetricsMessage(buf): MetricsMessage { const jsonString = buf.toString() .substr('stat: '.length) // remove leading "stat: " .replace(/\0/g, ''); // remove trailing null terminator diff --git a/src/shadowbox/server/main.ts b/src/shadowbox/server/main.ts index 314b439f5..635b6a2ed 100644 --- a/src/shadowbox/server/main.ts +++ b/src/shadowbox/server/main.ts @@ -19,15 +19,41 @@ import * as restify from 'restify'; import {FilesystemTextFile} from '../infrastructure/filesystem_text_file'; import * as ip_location from '../infrastructure/ip_location'; +import * as json_config from '../infrastructure/json_config'; import * as logging from '../infrastructure/logging'; import {LibevShadowsocksServer} from './libev_shadowsocks_server'; +import {ManagerMetrics, ManagerMetricsJson} from './manager_metrics'; import {bindService, ShadowsocksManagerService} from './manager_service'; -import * as metrics from './metrics'; import {createServerAccessKeyRepository} from './server_access_key'; import * as server_config from './server_config'; +import {SharedMetrics, SharedMetricsJson} from './shared_metrics'; const DEFAULT_STATE_DIR = '/root/shadowbox/persisted-state'; +const MAX_STATS_FILE_AGE_MS = 5000; + +// Serialized format for the metrics file. +// WARNING: Renaming fields will break backwards-compatibility. +interface MetricsConfigJson { + // Serialized ManagerStats object. + transferStats?: ManagerMetricsJson; + // Serialized SharedStats object. + hourlyMetrics?: SharedMetricsJson; +} + +function readMetricsConfig(filename: string): json_config.JsonConfig { + try { + const metricsConfig = json_config.loadFileConfig(filename); + // Make sure we have non-empty sub-configs. + metricsConfig.data().transferStats = + metricsConfig.data().transferStats || {} as ManagerMetricsJson; + metricsConfig.data().hourlyMetrics = + metricsConfig.data().hourlyMetrics || {} as SharedMetricsJson; + return new json_config.DelayedConfig(metricsConfig, MAX_STATS_FILE_AGE_MS); + } catch (error) { + throw new Error(`Failed to read metrics config at ${filename}: ${error}`); + } +} function main() { const verbose = process.env.LOG_LEVEL === 'debug'; @@ -56,36 +82,27 @@ function main() { process.exit(1); } - const serverConfig = new server_config.ServerConfig( - new FilesystemTextFile(getPersistentFilename('shadowbox_server_config.json')), - process.env.SB_DEFAULT_SERVER_NAME); + const serverConfig = + server_config.readServerConfig(getPersistentFilename('shadowbox_server_config.json')); const shadowsocksServer = new LibevShadowsocksServer(proxyHostname, verbose); - const statsFilename = getPersistentFilename('shadowbox_stats.json'); - const stats = new metrics.PersistentStats(statsFilename); - const ipLocationService = new ip_location.MmdbLocationService(); - stats.onLastHourMetricsReady((startDatetime, endDatetime, lastHourUserStats) => { - if (serverConfig.getMetricsEnabled()) { - metrics - .getHourlyServerMetricsReport( - serverConfig.serverId, startDatetime, endDatetime, lastHourUserStats, - ipLocationService) - .then((report) => { - if (report) { - metrics.postHourlyServerMetricsReports(report, metricsUrl); - } - }); - } - }); + const metricsConfig = readMetricsConfig(getPersistentFilename('shadowbox_stats.json')); + const managerMetrics = new ManagerMetrics( + new json_config.ChildConfig(metricsConfig, metricsConfig.data().transferStats)); + const sharedMetrics = new SharedMetrics( + new json_config.ChildConfig(metricsConfig, metricsConfig.data().hourlyMetrics), serverConfig, + metricsUrl, new ip_location.MmdbLocationService()); logging.info('Starting...'); const userConfigFilename = getPersistentFilename('shadowbox_config.json'); createServerAccessKeyRepository( - proxyHostname, new FilesystemTextFile(userConfigFilename), shadowsocksServer, stats) + proxyHostname, new FilesystemTextFile(userConfigFilename), shadowsocksServer, managerMetrics, + sharedMetrics) .then((accessKeyRepository) => { - const managerService = - new ShadowsocksManagerService(serverConfig, accessKeyRepository, stats); + const managerService = new ShadowsocksManagerService( + process.env.SB_DEFAULT_SERVER_NAME || 'Outline Server', serverConfig, + accessKeyRepository, managerMetrics); const certificateFilename = process.env.SB_CERTIFICATE_FILE; const privateKeyFilename = process.env.SB_PRIVATE_KEY_FILE; diff --git a/src/shadowbox/server/manager_metrics.spec.ts b/src/shadowbox/server/manager_metrics.spec.ts new file mode 100644 index 000000000..aae945b33 --- /dev/null +++ b/src/shadowbox/server/manager_metrics.spec.ts @@ -0,0 +1,48 @@ +// Copyright 2018 The Outline Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import {InMemoryConfig} from '../infrastructure/json_config'; + +import {ManagerMetrics, ManagerMetricsJson} from './manager_metrics'; + +function addDays(baseDate: Date, days: number) { + const date = new Date(baseDate); + date.setDate(baseDate.getDate() + days); + return date; +} + +describe('ManagerMetrics', () => { + it('Saves traffic to config', (done) => { + const now = new Date(); + const config = new InMemoryConfig({} as ManagerMetricsJson); + const metrics = new ManagerMetrics(config); + + let report = metrics.get30DayByteTransfer(); + expect(report.bytesTransferredByUserId).toEqual({}); + + for (let di = 0; di < 40; di++) { + metrics.recordBytesTransferred(addDays(now, -di), 'user-0', 1); + } + report = metrics.get30DayByteTransfer(); + // This is being dropped + expect(report.bytesTransferredByUserId).toEqual({'user-0': 30}); + // We are not cleaning this from the config. + expect(config.mostRecentWrite.userIdSet).toEqual(['user-0']); + expect(Object.keys(config.mostRecentWrite.dailyUserBytesTransferred).length).toEqual(40); + + expect(new ManagerMetrics(new InMemoryConfig(config.mostRecentWrite)).get30DayByteTransfer()) + .toEqual(report); + done(); + }); +}); diff --git a/src/shadowbox/server/manager_metrics.ts b/src/shadowbox/server/manager_metrics.ts new file mode 100644 index 000000000..7deec0da7 --- /dev/null +++ b/src/shadowbox/server/manager_metrics.ts @@ -0,0 +1,93 @@ +// Copyright 2018 The Outline Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import {JsonConfig} from '../infrastructure/json_config'; +import {AccessKeyId} from '../model/access_key'; +import {DataUsageByUser} from '../model/metrics'; + +// Serialized format for the manager metrics. +// WARNING: Renaming fields will break backwards-compatibility. +export interface ManagerMetricsJson { + // Bytes per user per day. The key encodes the user+day in the form "userId-dateInYYYYMMDD". + dailyUserBytesTransferred?: Array<[string, number]>; + // Set of all User IDs for whom we have transfer metrics. + // TODO: Delete userIdSet. It can be derived from dailyUserBytesTransferred. + userIdSet?: string[]; +} + +// ManagerMetrics keeps track of the number of bytes transferred per user, per day. +// Surfaced by the manager service to display on the Manager UI. +// TODO: Remove entries older than 30d. +export class ManagerMetrics { + private dailyUserBytesTransferred: Map; + private userIdSet: Set; + + constructor(private config: JsonConfig) { + const serializedObject = config.data(); + if (serializedObject) { + this.dailyUserBytesTransferred = new Map(serializedObject.dailyUserBytesTransferred); + this.userIdSet = new Set(serializedObject.userIdSet); + } else { + this.dailyUserBytesTransferred = new Map(); + this.userIdSet = new Set(); + } + } + + public recordBytesTransferred(date: Date, userId: AccessKeyId, numBytes: number) { + this.userIdSet.add(userId); + + const oldTotal = this.getBytes(userId, date); + const newTotal = oldTotal + numBytes; + this.dailyUserBytesTransferred.set(this.getKey(userId, date), newTotal); + this.toJson(this.config.data()); + this.config.write(); + } + + public get30DayByteTransfer(): DataUsageByUser { + const bytesTransferredByUserId = {}; + for (let i = 0; i < 30; ++i) { + // Get Date from i days ago. + const d = new Date(); + d.setDate(d.getDate() - i); + + // Get transfer per userId and total + for (const userId of this.userIdSet) { + if (!bytesTransferredByUserId[userId]) { + bytesTransferredByUserId[userId] = 0; + } + const numBytes = this.getBytes(userId, d); + bytesTransferredByUserId[userId] += numBytes; + } + } + return {bytesTransferredByUserId}; + } + + // Returns the state of this object, e.g. + // {"dailyUserBytesTransferred":[["0-20170816",100],["1-20170816",100]],"userIdSet":["0","1"]} + private toJson(target: ManagerMetricsJson) { + // Use [...] operator to serialize Map and Set objects to JSON. + target.dailyUserBytesTransferred = [...this.dailyUserBytesTransferred]; + target.userIdSet = [...this.userIdSet]; + } + + private getBytes(userId: AccessKeyId, d: Date) { + const key = this.getKey(userId, d); + return this.dailyUserBytesTransferred.get(key) || 0; + } + + private getKey(userId: AccessKeyId, d: Date) { + const yyyymmdd = d.toISOString().substr(0, 'YYYY-MM-DD'.length).replace(/-/g, ''); + return `${userId}-${yyyymmdd}`; + } +} diff --git a/src/shadowbox/server/manager_service.spec.ts b/src/shadowbox/server/manager_service.spec.ts index 5f506b18d..1fcac44e9 100644 --- a/src/shadowbox/server/manager_service.spec.ts +++ b/src/shadowbox/server/manager_service.spec.ts @@ -12,11 +12,12 @@ // See the License for the specific language governing permissions and // limitations under the License. +import {InMemoryConfig} from '../infrastructure/json_config'; import {AccessKey, AccessKeyRepository} from '../model/access_key'; import {ShadowsocksManagerService} from './manager_service'; -import {InMemoryFile, MockAccessKeyRepository} from './mocks/mocks'; -import {ServerConfig} from './server_config'; +import {MockAccessKeyRepository} from './mocks/mocks'; +import {ServerConfigJson} from './server_config'; interface ServerInfo { name: string; @@ -34,128 +35,106 @@ describe('ShadowsocksManagerService', () => { expect(responseProcessed).toEqual(true); }); - it('Return default name by default', (done) => { - const repo = new MockAccessKeyRepository(); - const serverConfig = new ServerConfig(new InMemoryFile(true), 'default name'); - const service = new ShadowsocksManagerService(serverConfig, repo, null); - service.getServer( - {params: {}}, { - send: (httpCode, data: ServerInfo) => { - expect(httpCode).toEqual(200); - expect(data.name).toEqual('default name'); - responseProcessed = true; - } - }, - done); - }); - - it('Rename changes the server name', (done) => { - const repo = new MockAccessKeyRepository(); - const serverConfig = new ServerConfig(new InMemoryFile(true), 'default name'); - const service = new ShadowsocksManagerService(serverConfig, repo, null); - service.renameServer( - {params: {name: 'new name'}}, { - send: (httpCode, _) => { - expect(httpCode).toEqual(204); - expect(serverConfig.getName()).toEqual('new name'); - responseProcessed = true; - } - }, - done); - }); - - it('lists access keys in order', (done) => { - const repo = new MockAccessKeyRepository(); - const service = new ShadowsocksManagerService(null, repo, null); - - // Create 2 access keys with names. - Promise - .all([ - createNewAccessKeyWithName(repo, 'keyName1'), createNewAccessKeyWithName(repo, 'keyName2') - ]) - .then((keys) => { - // Verify that response returns keys in correct order with correct names. - const res = { - send: (httpCode, data) => { + describe('getServer', () => { + it('Return default name if name is absent', (done) => { + const repo = new MockAccessKeyRepository(); + const serverConfig = new InMemoryConfig({} as ServerConfigJson); + const service = new ShadowsocksManagerService('default name', serverConfig, repo, null); + service.getServer( + {params: {}}, { + send: (httpCode, data: ServerInfo) => { expect(httpCode).toEqual(200); - expect(data.accessKeys.length).toEqual(2); - expect(data.accessKeys[0].name).toEqual(keys[0].name); - expect(data.accessKeys[0].id).toEqual(keys[0].id); - expect(data.accessKeys[1].name).toEqual(keys[1].name); - expect(data.accessKeys[1].id).toEqual(keys[1].id); - responseProcessed = true; // required for afterEach to pass. + expect(data.name).toEqual('default name'); + responseProcessed = true; } - }; - service.listAccessKeys({params: {}}, res, done); - }); - }); - - it('creates keys', (done) => { - const repo = new MockAccessKeyRepository(); - const service = new ShadowsocksManagerService(null, repo, null); - - // Verify that response returns a key with the expected properties. - const res = { - send: (httpCode, data) => { - expect(httpCode).toEqual(201); - const expectedProperties = ['id', 'name', 'password', 'port', 'method', 'accessUrl']; - expect(Object.keys(data).sort()).toEqual(expectedProperties.sort()); - responseProcessed = true; // required for afterEach to pass. - } - }; - service.createNewAccessKey({params: {}}, res, done); + }, + done); + }); + it('Return saved name', (done) => { + const repo = new MockAccessKeyRepository(); + const serverConfig = new InMemoryConfig({name: 'Server'} as ServerConfigJson); + const service = new ShadowsocksManagerService('default name', serverConfig, repo, null); + service.getServer( + {params: {}}, { + send: (httpCode, data: ServerInfo) => { + expect(httpCode).toEqual(200); + expect(data.name).toEqual('Server'); + responseProcessed = true; + } + }, + done); + }); }); - it('removes keys', (done) => { - const repo = new MockAccessKeyRepository(); - const service = new ShadowsocksManagerService(null, repo, null); - - // Create 2 access keys with names. - Promise - .all([ - createNewAccessKeyWithName(repo, 'keyName1'), createNewAccessKeyWithName(repo, 'keyName2') - ]) - .then((keys) => { - const res = { - send: (httpCode, data) => { + describe('renameServer', () => { + it('Rename changes the server name', (done) => { + const repo = new MockAccessKeyRepository(); + const serverConfig = new InMemoryConfig({} as ServerConfigJson); + const service = new ShadowsocksManagerService('default name', serverConfig, repo, null); + service.renameServer( + {params: {name: 'new name'}}, { + send: (httpCode, _) => { expect(httpCode).toEqual(204); - // expect that the only remaining key is the 2nd key we created. - expect(getFirstAccessKey(repo).id === keys[1].id); - responseProcessed = true; // required for afterEach to pass. + expect(serverConfig.mostRecentWrite.name).toEqual('new name'); + responseProcessed = true; } - }; - // remove the 1st key. - service.removeAccessKey({params: {id: keys[0].id}}, res, done); - }); + }, + done); + }); }); - it('renames keys', (done) => { - const repo = new MockAccessKeyRepository(); - const service = new ShadowsocksManagerService(null, repo, null); - const OLD_NAME = 'oldName'; - const NEW_NAME = 'newName'; + describe('listAccessKeys', () => { + it('lists access keys in order', (done) => { + const repo = new MockAccessKeyRepository(); + const service = new ShadowsocksManagerService('default name', null, repo, null); - createNewAccessKeyWithName(repo, OLD_NAME).then((key) => { - expect(getFirstAccessKey(repo).name === OLD_NAME); + // Create 2 access keys with names. + Promise + .all([ + createNewAccessKeyWithName(repo, 'keyName1'), + createNewAccessKeyWithName(repo, 'keyName2') + ]) + .then((keys) => { + // Verify that response returns keys in correct order with correct names. + const res = { + send: (httpCode, data) => { + expect(httpCode).toEqual(200); + expect(data.accessKeys.length).toEqual(2); + expect(data.accessKeys[0].name).toEqual(keys[0].name); + expect(data.accessKeys[0].id).toEqual(keys[0].id); + expect(data.accessKeys[1].name).toEqual(keys[1].name); + expect(data.accessKeys[1].id).toEqual(keys[1].id); + responseProcessed = true; // required for afterEach to pass. + } + }; + service.listAccessKeys({params: {}}, res, done); + }); + }); + }); + + describe('createNewAccessKey', () => { + it('creates keys', (done) => { + const repo = new MockAccessKeyRepository(); + const service = new ShadowsocksManagerService('default name', null, repo, null); + + // Verify that response returns a key with the expected properties. const res = { send: (httpCode, data) => { - expect(httpCode).toEqual(204); - expect(getFirstAccessKey(repo).name === NEW_NAME); + expect(httpCode).toEqual(201); + const expectedProperties = ['id', 'name', 'password', 'port', 'method', 'accessUrl']; + expect(Object.keys(data).sort()).toEqual(expectedProperties.sort()); responseProcessed = true; // required for afterEach to pass. } }; - service.renameAccessKey({params: {id: key.id, name: NEW_NAME}}, res, done); + service.createNewAccessKey({params: {}}, res, done); }); - }); + it('Create returns a 500 when the repository throws an exception', (done) => { + const repo = new MockAccessKeyRepository(); + spyOn(repo, 'createNewAccessKey').and.throwError('cannot write to disk'); + const service = new ShadowsocksManagerService('default name', null, repo, null); - it('Rename returns a 500 when the repository throws an exception', (done) => { - const repo = new MockAccessKeyRepository(); - spyOn(repo, 'renameAccessKey').and.throwError('cannot write to disk'); - const service = new ShadowsocksManagerService(null, repo, null); - - createNewAccessKeyWithName(repo, 'oldName').then((key) => { const res = {send: (httpCode, data) => {}}; - service.renameAccessKey({params: {id: key.id, name: 'newName'}}, res, (error) => { + service.createNewAccessKey({params: {}}, res, (error) => { expect(error.statusCode).toEqual(500); responseProcessed = true; // required for afterEach to pass. done(); @@ -163,34 +142,125 @@ describe('ShadowsocksManagerService', () => { }); }); - it('Create returns a 500 when the repository throws an exception', (done) => { - const repo = new MockAccessKeyRepository(); - spyOn(repo, 'createNewAccessKey').and.throwError('cannot write to disk'); - const service = new ShadowsocksManagerService(null, repo, null); + describe('removeAccessKey', () => { + it('removes keys', (done) => { + const repo = new MockAccessKeyRepository(); + const service = new ShadowsocksManagerService('default name', null, repo, null); + + // Create 2 access keys with names. + Promise + .all([ + createNewAccessKeyWithName(repo, 'keyName1'), + createNewAccessKeyWithName(repo, 'keyName2') + ]) + .then((keys) => { + const res = { + send: (httpCode, data) => { + expect(httpCode).toEqual(204); + // expect that the only remaining key is the 2nd key we created. + expect(getFirstAccessKey(repo).id === keys[1].id); + responseProcessed = true; // required for afterEach to pass. + } + }; + // remove the 1st key. + service.removeAccessKey({params: {id: keys[0].id}}, res, done); + }); + }); + it('Remove returns a 500 when the repository throws an exception', (done) => { + const repo = new MockAccessKeyRepository(); + spyOn(repo, 'removeAccessKey').and.throwError('cannot write to disk'); + const service = new ShadowsocksManagerService('default name', null, repo, null); - const res = {send: (httpCode, data) => {}}; - service.createNewAccessKey({params: {}}, res, (error) => { - expect(error.statusCode).toEqual(500); - responseProcessed = true; // required for afterEach to pass. - done(); + // Create 2 access keys with names. + createNewAccessKeyWithName(repo, 'keyName1').then((key) => { + const res = {send: (httpCode, data) => {}}; + service.removeAccessKey({params: {id: key.id}}, res, (error) => { + expect(error.statusCode).toEqual(500); + responseProcessed = true; // required for afterEach to pass. + done(); + }); + }); }); }); - it('Remove returns a 500 when the repository throws an exception', (done) => { - const repo = new MockAccessKeyRepository(); - spyOn(repo, 'removeAccessKey').and.throwError('cannot write to disk'); - const service = new ShadowsocksManagerService(null, repo, null); + describe('renameAccessKey', () => { + it('renames keys', (done) => { + const repo = new MockAccessKeyRepository(); + const service = new ShadowsocksManagerService('default name', null, repo, null); + const OLD_NAME = 'oldName'; + const NEW_NAME = 'newName'; - // Create 2 access keys with names. - createNewAccessKeyWithName(repo, 'keyName1').then((key) => { - const res = {send: (httpCode, data) => {}}; - service.removeAccessKey({params: {id: key.id}}, res, (error) => { - expect(error.statusCode).toEqual(500); - responseProcessed = true; // required for afterEach to pass. - done(); + createNewAccessKeyWithName(repo, OLD_NAME).then((key) => { + expect(getFirstAccessKey(repo).name === OLD_NAME); + const res = { + send: (httpCode, data) => { + expect(httpCode).toEqual(204); + expect(getFirstAccessKey(repo).name === NEW_NAME); + responseProcessed = true; // required for afterEach to pass. + } + }; + service.renameAccessKey({params: {id: key.id, name: NEW_NAME}}, res, done); + }); + }); + it('Rename returns a 500 when the repository throws an exception', (done) => { + const repo = new MockAccessKeyRepository(); + spyOn(repo, 'renameAccessKey').and.throwError('cannot write to disk'); + const service = new ShadowsocksManagerService('default name', null, repo, null); + + createNewAccessKeyWithName(repo, 'oldName').then((key) => { + const res = {send: (httpCode, data) => {}}; + service.renameAccessKey({params: {id: key.id, name: 'newName'}}, res, (error) => { + expect(error.statusCode).toEqual(500); + responseProcessed = true; // required for afterEach to pass. + done(); + }); }); }); }); + + describe('getShareMetrics', () => { + it('Returns value from config', (done) => { + const serverConfig = new InMemoryConfig({metricsEnabled: true} as ServerConfigJson); + const service = new ShadowsocksManagerService('default name', serverConfig, null, null); + service.getShareMetrics( + {params: {}}, { + send: (httpCode, data: {metricsEnabled: boolean}) => { + expect(httpCode).toEqual(200); + expect(data.metricsEnabled).toEqual(true); + responseProcessed = true; + } + }, + done); + }); + it('Returns false by default', (done) => { + const serverConfig = new InMemoryConfig({} as ServerConfigJson); + const service = new ShadowsocksManagerService('default name', serverConfig, null, null); + service.getShareMetrics( + {params: {}}, { + send: (httpCode, data: {metricsEnabled: boolean}) => { + expect(httpCode).toEqual(200); + expect(data.metricsEnabled).toEqual(false); + responseProcessed = true; + } + }, + done); + }); + }); + describe('setShareMetrics', () => { + it('Sets value in the config', (done) => { + const serverConfig = new InMemoryConfig({metricsEnabled: false} as ServerConfigJson); + const service = new ShadowsocksManagerService('default name', serverConfig, null, null); + service.setShareMetrics( + {params: {metricsEnabled: true}}, { + send: (httpCode, _) => { + expect(httpCode).toEqual(204); + expect(serverConfig.mostRecentWrite.metricsEnabled).toEqual(true); + responseProcessed = true; + } + }, + done); + }); + }); }); function getFirstAccessKey(repo: AccessKeyRepository) { diff --git a/src/shadowbox/server/manager_service.ts b/src/shadowbox/server/manager_service.ts index 38abd8a46..93f9f2a9a 100644 --- a/src/shadowbox/server/manager_service.ts +++ b/src/shadowbox/server/manager_service.ts @@ -15,11 +15,12 @@ import * as restify from 'restify'; import {makeConfig, SIP002_URI} from 'ShadowsocksConfig/shadowsocks_config'; +import {JsonConfig} from '../infrastructure/json_config'; import * as logging from '../infrastructure/logging'; import {AccessKey, AccessKeyRepository} from '../model/access_key'; -import * as metrics_model from '../model/metrics'; -import * as metrics from './metrics'; -import * as server_config from './server_config'; + +import {ManagerMetrics} from './manager_metrics'; +import {ServerConfigJson} from './server_config'; // Creates a AccessKey response. function accessKeyToJson(accessKey: AccessKey) { @@ -47,6 +48,7 @@ function accessKeyToJson(accessKey: AccessKey) { interface RequestParams { id?: string; name?: string; + metricsEnabled?: boolean; } interface RequestType { params: RequestParams; @@ -79,9 +81,10 @@ interface SetShareMetricsParams { // for each existing access key, with the port and password assigned for that access key. export class ShadowsocksManagerService { constructor( - private serverConfig: server_config.ServerConfig, + private defaultServerName: string, + private serverConfig: JsonConfig, private accessKeys: AccessKeyRepository, - private stats: metrics.PersistentStats, + private managerMetrics: ManagerMetrics, ) {} public renameServer(req: RequestType, res: ResponseType, next: restify.Next): void { @@ -91,17 +94,18 @@ export class ShadowsocksManagerService { next(); return; } - this.serverConfig.setName(name); + this.serverConfig.data().name = name; + this.serverConfig.write(); res.send(204); next(); } public getServer(req: RequestType, res: ResponseType, next: restify.Next): void { res.send(200, { - name: this.serverConfig.getName(), - serverId: this.serverConfig.serverId, - metricsEnabled: this.serverConfig.getMetricsEnabled(), - createdTimestampMs: this.serverConfig.getCreatedTimestampMs() + name: this.serverConfig.data().name || this.defaultServerName, + serverId: this.serverConfig.data().serverId, + metricsEnabled: this.serverConfig.data().metricsEnabled || false, + createdTimestampMs: this.serverConfig.data().createdTimestampMs }); next(); } @@ -166,7 +170,7 @@ export class ShadowsocksManagerService { public async getDataUsage(req: RequestType, res: ResponseType, next: restify.Next) { try { - res.send(200, this.stats.get30DayByteTransfer()); + res.send(200, this.managerMetrics.get30DayByteTransfer()); return next(); } catch (error) { logging.error(error); @@ -175,14 +179,15 @@ export class ShadowsocksManagerService { } public getShareMetrics(req: RequestType, res: ResponseType, next: restify.Next): void { - res.send(200, {metricsEnabled: this.serverConfig.getMetricsEnabled()}); + res.send(200, {metricsEnabled: this.serverConfig.data().metricsEnabled || false}); next(); } public setShareMetrics(req: RequestType, res: ResponseType, next: restify.Next): void { const params = req.params as SetShareMetricsParams; if (typeof params.metricsEnabled === 'boolean') { - this.serverConfig.setMetricsEnabled(params.metricsEnabled); + this.serverConfig.data().metricsEnabled = params.metricsEnabled; + this.serverConfig.write(); res.send(204); } else { res.send(400); diff --git a/src/shadowbox/server/metrics.ts b/src/shadowbox/server/metrics.ts deleted file mode 100644 index 85ce12403..000000000 --- a/src/shadowbox/server/metrics.ts +++ /dev/null @@ -1,408 +0,0 @@ -// Copyright 2018 The Outline Authors -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -import * as events from 'events'; -import * as fs from 'fs'; -import * as url from 'url'; - -import * as file_read from '../infrastructure/file_read'; -import * as follow_redirects from '../infrastructure/follow_redirects'; -import * as ip_location from '../infrastructure/ip_location'; -import * as logging from '../infrastructure/logging'; -import {AccessKeyId} from '../model/access_key'; -import {DataUsageByUser, LastHourMetricsReadyCallback, PerUserStats, Stats} from '../model/metrics'; - -import * as ip_util from './ip_util'; - -const MS_PER_HOUR = 60 * 60 * 1000; - -interface PersistentStatsStoredData { - // Serialized TransferStats object. - transferStats: string; - // Serialized ConnectionStats object. - hourlyMetrics: string; -} - -// Stats implementation which reads and writes state to a JSON file containing -// a PersistentStatsStoredData object. -export class PersistentStats implements Stats { - private static readonly MAX_STATS_FILE_AGE_MS = 5000; - private transferStats: TransferStats; - private connectionStats: ConnectionStats; - private dirty = false; - private eventEmitter = new events.EventEmitter(); - private static readonly LAST_HOUR_METRICS_READY_EVENT = 'lastHourMetricsReady'; - - constructor(private filename) { - // Initialize stats from saved file, if available. - const persistedStateObj = this.readStateFile(); - if (persistedStateObj) { - this.transferStats = new TransferStats(persistedStateObj.transferStats); - this.connectionStats = new ConnectionStats(persistedStateObj.hourlyMetrics); - } else { - this.transferStats = new TransferStats(); - this.connectionStats = new ConnectionStats(); - } - - // Set write interval. - setInterval(this.writeStatsToFile.bind(this), PersistentStats.MAX_STATS_FILE_AGE_MS); - - // Set hourly metrics report interval - setHourlyInterval(this.generateHourlyReport.bind(this)); - } - - public recordBytesTransferred( - userId: AccessKeyId, metricsUserId: AccessKeyId, numBytes: number, ipAddresses: string[]) { - // Pass the userId (sequence number) to transferStats as this data is returned to the Outline - // manager which relies on the userId sequence number. - this.transferStats.recordBytesTransferred(userId, numBytes); - // Pass metricsUserId (uuid, rather than sequence number) to connectionStats - // as these values may be reported to the Outline metrics server. - this.connectionStats.recordBytesTransferred(metricsUserId, numBytes, ipAddresses); - this.dirty = true; - } - - public get30DayByteTransfer(): DataUsageByUser { - return this.transferStats.get30DayByteTransfer(); - } - - public onLastHourMetricsReady(callback: LastHourMetricsReadyCallback) { - this.eventEmitter.on(PersistentStats.LAST_HOUR_METRICS_READY_EVENT, callback); - - // Check if an hourly metrics report is already due (e.g. if server was shutdown over an - // hour ago and just restarted). - if (getHoursSinceDatetime(this.connectionStats.startDatetime) >= 1) { - this.generateHourlyReport(); - } - } - - private writeStatsToFile() { - if (!this.dirty) { - return; - } - - const statsSerialized = JSON.stringify({ - transferStats: this.transferStats.serialize(), - hourlyMetrics: this.connectionStats.serialize() - }); - - // Write to temporary file, then move that temporary file to the - // persistent location, to avoid accidentally breaking the stats file. - // Use *Sync calls for atomic operations, to guard against corrupting - // these files. - const tempFilename = `${this.filename}.${Date.now()}`; - try { - fs.writeFileSync(tempFilename, statsSerialized, {encoding: 'utf8'}); - fs.renameSync(tempFilename, this.filename); - this.dirty = false; - } catch (err) { - logging.error(`Error writing stats file ${err}`); - } - } - - private generateHourlyReport(): void { - if (this.connectionStats.lastHourUserStats.size === 0) { - // No connection stats to report. - return; - } - - this.eventEmitter.emit( - PersistentStats.LAST_HOUR_METRICS_READY_EVENT, this.connectionStats.startDatetime, - new Date(), // endDatetime is the current date and time. - this.connectionStats.lastHourUserStats); - - // Reset connection stats to begin recording the next hour. - this.connectionStats.reset(); - - // Update hasChange so we know to persist stats. - this.dirty = true; - } - - private readStateFile(): PersistentStatsStoredData { - const text = file_read.readFileIfExists(this.filename); - if (!text) { - return null; - } - try { - return JSON.parse(text); - } catch (e) { - return null; - } - } -} - -// TransferStats keeps track of the number of bytes transferred per user, per day. -class TransferStats { - // Key is a string in the form "userId-dateInYYYYMMDD", e.g. "3-20170726". - private dailyUserBytesTransferred: Map; - // Set of all User IDs for whom we have transfer stats. - private userIdSet: Set; - - constructor(serializedObject?: {}) { - if (serializedObject) { - this.deserialize(serializedObject); - } else { - this.dailyUserBytesTransferred = new Map(); - this.userIdSet = new Set(); - } - } - - public recordBytesTransferred(userId: AccessKeyId, numBytes: number) { - this.userIdSet.add(userId); - - const d = new Date(); - const oldTotal = this.getBytes(userId, d); - const newTotal = oldTotal + numBytes; - this.dailyUserBytesTransferred.set(this.getKey(userId, d), newTotal); - } - - public get30DayByteTransfer(): DataUsageByUser { - const bytesTransferredByUserId = {}; - for (let i = 0; i < 30; ++i) { - // Get Date from i days ago. - const d = new Date(); - d.setDate(d.getDate() - i); - - // Get transfer per userId and total - for (const userId of this.userIdSet) { - if (!bytesTransferredByUserId[userId]) { - bytesTransferredByUserId[userId] = 0; - } - const numBytes = this.getBytes(userId, d); - bytesTransferredByUserId[userId] += numBytes; - } - } - return {bytesTransferredByUserId}; - } - - // Returns the state of this object, e.g. - // {"dailyUserBytesTransferred":[["0-20170816",100],["1-20170816",100]],"userIdSet":["0","1"]} - public serialize(): {} { - return { - // Use [...] operator to serialize Map and Set objects to JSON. - dailyUserBytesTransferred: [...this.dailyUserBytesTransferred], - userIdSet: [...this.userIdSet] - }; - } - - private deserialize(serializedObject: {}) { - this.dailyUserBytesTransferred = new Map(serializedObject['dailyUserBytesTransferred']); - this.userIdSet = new Set(serializedObject['userIdSet']); - } - - private getBytes(userId: AccessKeyId, d: Date) { - const key = this.getKey(userId, d); - return this.dailyUserBytesTransferred.get(key) || 0; - } - - private getKey(userId: AccessKeyId, d: Date) { - const yyyymmdd = d.toISOString().substr(0, 'YYYY-MM-DD'.length).replace(/-/g, ''); - return `${userId}-${yyyymmdd}`; - } -} - -// Keeps track of the connection stats per user, sine the startDatetime. -class ConnectionStats { - // Date+time at which we started recording connection stats, e.g. - // in case this object is constructed from data written to disk. - public startDatetime: Date; - - // Map from the metrics AccessKeyId to stats (bytes transferred, IP addresses). - public lastHourUserStats: Map; - - constructor(serializedObject?: {}) { - if (serializedObject) { - this.deserialize(serializedObject); - } else { - this.startDatetime = new Date(); - this.lastHourUserStats = new Map(); - } - } - - // CONSIDER: accepting hashedIpAddresses, which can be persisted to disk - // and reported to the metrics server (to approximate number of devices per userId). - public recordBytesTransferred(userId: AccessKeyId, numBytes: number, ipAddresses: string[]) { - const perUserStats = this.lastHourUserStats.get(userId) || - {bytesTransferred: 0, anonymizedIpAddresses: new Set()}; - perUserStats.bytesTransferred += numBytes; - const anonymizedIpAddresses = getAnonymizedAndDedupedIpAddresses(ipAddresses); - for (const ip of anonymizedIpAddresses) { - perUserStats.anonymizedIpAddresses.add(ip); - } - this.lastHourUserStats.set(userId, perUserStats); - } - - public reset(): void { - this.lastHourUserStats = new Map(); - this.startDatetime = new Date(); - } - - // Returns the state of this object, e.g. - // {"startTimestamp":1502896650353,"lastHourUserStatsObj":{"0":{"bytesTransferred":100,"anonymizedIpAddresses":["2620:0:1003:0:0:0:0:0","5.2.79.0"]}}} - public serialize(): {} { - // lastHourUserStats is a Map containing Set structures. Convert to an object - // with array values. - const lastHourUserStatsObj = {}; - this.lastHourUserStats.forEach((perUserStats, userId) => { - lastHourUserStatsObj[userId] = { - bytesTransferred: perUserStats.bytesTransferred, - anonymizedIpAddresses: [...perUserStats.anonymizedIpAddresses] - }; - }); - return {startTimestamp: this.startDatetime.getTime(), lastHourUserStatsObj}; - } - - private deserialize(serializedObject: {}) { - // Convert type of lastHourUserStatsObj from Object containing Arrays to - // Map containing Sets. - const lastHourUserStatsMap = new Map(); - Object.keys(serializedObject['lastHourUserStatsObj']).map((userId) => { - const perUserStatsObj = serializedObject['lastHourUserStatsObj'][userId]; - lastHourUserStatsMap.set(userId, { - bytesTransferred: perUserStatsObj.bytesTransferred, - anonymizedIpAddresses: new Set(perUserStatsObj.anonymizedIpAddresses) - }); - }); - - this.startDatetime = new Date(serializedObject['startTimestamp']); - this.lastHourUserStats = lastHourUserStatsMap; - } -} - -export function getHourlyServerMetricsReport( - serverId: string, startDatetime: Date, endDatetime: Date, - lastHourUserStats: Map, - ipLocationService: ip_location.IpLocationService): Promise { - if (lastHourUserStats.size === 0) { - // Stats are empty, no need to post a report - return Promise.resolve(null); - } - // convert lastHourUserStats to an array HourlyUserMetricsReport - const userReportPromises = []; - lastHourUserStats.forEach((perUserStats, userId) => { - userReportPromises.push(getHourlyUserMetricsReport(userId, perUserStats, ipLocationService)); - }); - return Promise.all(userReportPromises).then((userReports: HourlyUserMetricsReport[]) => { - // Remove any userReports containing sanctioned countries, and return - // null if no reports remain with un-sanctioned countries. - userReports = getWithoutSanctionedReports(userReports); - if (userReports.length === 0) { - return null; - } - return { - serverId, - startUtcMs: startDatetime.getTime(), - endUtcMs: endDatetime.getTime(), - userReports - }; - }); -} - -export function postHourlyServerMetricsReports( - report: HourlyServerMetricsReport, metricsUrl: string) { - const options = { - url: metricsUrl, - headers: {'Content-Type': 'application/json'}, - method: 'POST', - body: JSON.stringify(report) - }; - logging.info('Posting metrics: ' + JSON.stringify(options)); - return follow_redirects.requestFollowRedirectsWithSameMethodAndBody( - options, (error, response, body) => { - if (error) { - logging.error(`Error posting metrics: ${error}`); - return; - } - logging.info('Metrics server responded with status ' + response.statusCode); - }); -} - -function setHourlyInterval(callback: Function) { - const msUntilNextHour = MS_PER_HOUR - (Date.now() % MS_PER_HOUR); - setTimeout(() => { - setInterval(callback, MS_PER_HOUR); - callback(); - }, msUntilNextHour); -} - -// Returns the floating-point number of hours passed since the specified date. -function getHoursSinceDatetime(d: Date): number { - const deltaMs = Date.now() - d.getTime(); - return deltaMs / (MS_PER_HOUR); -} - -interface HourlyServerMetricsReport { - serverId: string; - startUtcMs: number; - endUtcMs: number; - userReports: HourlyUserMetricsReport[]; -} - -interface HourlyUserMetricsReport { - userId: string; - countries: string[]; - bytesTransferred: number; -} - -function getHourlyUserMetricsReport( - userId: AccessKeyId, perUserStats: PerUserStats, - ipLocationService: ip_location.IpLocationService): Promise { - const countryPromises = []; - for (const ip of perUserStats.anonymizedIpAddresses) { - const countryPromise = ipLocationService.countryForIp(ip).catch((e) => { - logging.warn(`Failed countryForIp call: ${e}`); - return 'ERROR'; - }); - countryPromises.push(countryPromise); - } - return Promise.all(countryPromises).then((countries: string[]) => { - return { - userId, - bytesTransferred: perUserStats.bytesTransferred, - countries: getWithoutDuplicates(countries) - }; - }); -} - -function getAnonymizedAndDedupedIpAddresses(ipAddresses: string[]): Set { - const s = new Set(); - for (const ip of ipAddresses) { - try { - s.add(ip_util.anonymizeIp(ip)); - } catch (err) { - logging.error('error anonymizing IP address: ' + ip + ', ' + err); - } - } - return s; -} - -// Return an array with the duplicate elements removed. -function getWithoutDuplicates(a: T[]): T[] { - return [...new Set(a)]; -} - -function getWithoutSanctionedReports(userReports: HourlyUserMetricsReport[]): - HourlyUserMetricsReport[] { - const sanctionedCountries = ['CU', 'IR', 'KP', 'SY']; - const filteredReports = []; - for (const userReport of userReports) { - userReport.countries = userReport.countries.filter((country) => { - return sanctionedCountries.indexOf(country) === -1; - }); - if (userReport.countries.length > 0) { - filteredReports.push(userReport); - } - } - return filteredReports; -} diff --git a/src/shadowbox/server/mocks/mocks.ts b/src/shadowbox/server/mocks/mocks.ts index bae7548ad..ae253d2ad 100644 --- a/src/shadowbox/server/mocks/mocks.ts +++ b/src/shadowbox/server/mocks/mocks.ts @@ -15,7 +15,6 @@ import * as dgram from 'dgram'; import {AccessKey, AccessKeyId, AccessKeyRepository} from '../../model/access_key'; -import {Stats} from '../../model/metrics'; import {ShadowsocksInstance} from '../../model/shadowsocks_server'; import {TextFile} from '../../model/text_file'; @@ -72,25 +71,13 @@ class MockShadowsocksInstance implements ShadowsocksInstance { export class MockShadowsocksServer { startInstance( - portNumber: number, password: string, statsSocket: dgram.Socket, + portNumber: number, password: string, metricsSocket: dgram.Socket, encryptionMethod?: string): Promise { const mock = new MockShadowsocksInstance(portNumber, password, encryptionMethod); return Promise.resolve(mock); } } -export class MockStats { - recordBytesTransferred( - userId: AccessKeyId, - metricsUserId: AccessKeyId, - numBytes: number, - ipAddresses: string[]) {} - onLastHourMetricsReady(callback) {} - get30DayByteTransfer() { - return {bytesTransferredByUserId: {}}; - } -} - export class InMemoryFile implements TextFile { private savedText: string; constructor(private exists: boolean) {} diff --git a/src/shadowbox/server/server_access_key.spec.ts b/src/shadowbox/server/server_access_key.spec.ts index 91db4efd1..d6eb69b2e 100644 --- a/src/shadowbox/server/server_access_key.spec.ts +++ b/src/shadowbox/server/server_access_key.spec.ts @@ -14,7 +14,7 @@ import {AccessKeyRepository} from '../model/access_key'; -import {InMemoryFile, MockShadowsocksServer, MockStats} from './mocks/mocks'; +import {InMemoryFile, MockShadowsocksServer} from './mocks/mocks'; import {createServerAccessKeyRepository} from './server_access_key'; describe('ServerAccessKeyRepository', () => { @@ -142,5 +142,5 @@ function countAccessKeys(repo: AccessKeyRepository) { function createRepo(inMemoryFile: InMemoryFile) { return createServerAccessKeyRepository( - 'hostname', inMemoryFile, new MockShadowsocksServer(), new MockStats()); + 'hostname', inMemoryFile, new MockShadowsocksServer(), null, null); } diff --git a/src/shadowbox/server/server_access_key.ts b/src/shadowbox/server/server_access_key.ts index 5ca852080..e4ae1618b 100644 --- a/src/shadowbox/server/server_access_key.ts +++ b/src/shadowbox/server/server_access_key.ts @@ -19,10 +19,12 @@ import * as uuidv4 from 'uuid/v4'; import {getRandomUnusedPort} from '../infrastructure/get_port'; import * as logging from '../infrastructure/logging'; import {AccessKey, AccessKeyId, AccessKeyRepository} from '../model/access_key'; -import {Stats} from '../model/metrics'; import {ShadowsocksInstance, ShadowsocksServer} from '../model/shadowsocks_server'; import {TextFile} from '../model/text_file'; +import {ManagerMetrics} from './manager_metrics'; +import {SharedMetrics} from './shared_metrics'; + // The format as json of access keys in the config file. interface AccessKeyConfig { id: AccessKeyId; @@ -88,17 +90,17 @@ class AccessKeyConfigFile { export function createServerAccessKeyRepository( proxyHostname: string, textFile: TextFile, shadowsocksServer: ShadowsocksServer, - stats: Stats): Promise { + managerMetrics: ManagerMetrics, sharedMetrics: SharedMetrics): Promise { const configFile = new AccessKeyConfigFile(textFile); - const configJson = configFile.loadConfig(); const reservedPorts = getReservedPorts(configJson.accessKeys); - // Create and save the stats socket. - return createBoundUdpSocket(reservedPorts).then((statsSocket) => { - reservedPorts.add(statsSocket.address().port); + // Create and save the metrics socket. + return createBoundUdpSocket(reservedPorts).then((metricsSocket) => { + reservedPorts.add(metricsSocket.address().port); return new ServerAccessKeyRepository( - proxyHostname, configFile, configJson, shadowsocksServer, statsSocket, stats); + proxyHostname, configFile, configJson, shadowsocksServer, metricsSocket, managerMetrics, + sharedMetrics); }); } @@ -127,7 +129,8 @@ class ServerAccessKeyRepository implements AccessKeyRepository { constructor( private proxyHostname: string, private configFile: AccessKeyConfigFile, private configJson: ConfigJson, private shadowsocksServer: ShadowsocksServer, - private statsSocket: dgram.Socket, private stats: Stats) { + private metricsSocket: dgram.Socket, private managerMetrics: ManagerMetrics, + private sharedMetrics: SharedMetrics) { for (const accessKeyJson of this.configJson.accessKeys) { this.startInstance(accessKeyJson).catch((error) => { logging.error(`Failed to start Shadowsocks instance for key ${accessKeyJson.id}: ${error}`); @@ -200,7 +203,7 @@ class ServerAccessKeyRepository implements AccessKeyRepository { private startInstance(accessKeyJson: AccessKeyConfig): Promise { return this.shadowsocksServer .startInstance( - accessKeyJson.port, accessKeyJson.password, this.statsSocket, + accessKeyJson.port, accessKeyJson.password, this.metricsSocket, accessKeyJson.encryptionMethod) .then((ssInstance) => { ssInstance.onInboundBytes( @@ -212,7 +215,8 @@ class ServerAccessKeyRepository implements AccessKeyRepository { private handleInboundBytes( accessKeyId: AccessKeyId, metricsId: AccessKeyId, inboundBytes: number, ipAddresses: string[]) { - this.stats.recordBytesTransferred(accessKeyId, metricsId, inboundBytes, ipAddresses); + this.managerMetrics.recordBytesTransferred(new Date(), accessKeyId, inboundBytes); + this.sharedMetrics.recordBytesTransferred(metricsId, inboundBytes, ipAddresses); } private saveConfig() { diff --git a/src/shadowbox/server/server_config.ts b/src/shadowbox/server/server_config.ts index dc4e695fc..a6a17ddd3 100644 --- a/src/shadowbox/server/server_config.ts +++ b/src/shadowbox/server/server_config.ts @@ -14,98 +14,26 @@ import * as uuidv4 from 'uuid/v4'; -import * as logging from '../infrastructure/logging'; -import {TextFile} from '../model/text_file'; - -export class ServerConfig { - public serverId: string; - private metricsEnabled = false; - private name: string; - private createdTimestampMs: number; // Created timestamp in UTC milliseconds. - - constructor(private configFile: TextFile, defaultName?: string) { - // Initialize from filename if possible. - let configText = ''; - try { - configText = this.configFile.readFileSync(); - } catch (error) { - // Ignore if file doesn't exist yet. - if (error.code !== 'ENOENT') { - throw error; - } - } - if (configText) { - try { - const savedState = JSON.parse(configText); - if (savedState.serverId) { - this.serverId = savedState.serverId; - } - if (savedState.metricsEnabled) { - this.metricsEnabled = savedState.metricsEnabled; - } - if (savedState.name) { - this.name = savedState.name; - } - if (savedState.createdTimestampMs) { - this.createdTimestampMs = savedState.createdTimestampMs; - } - } catch (err) { - logging.error(`Error parsing config ${err}`); - } - } - - // Initialize to default values if file missing or not valid. - let dirty = false; - if (!this.serverId) { - this.serverId = uuidv4(); - dirty = true; - } - if (!this.name && defaultName) { - this.name = defaultName; - dirty = true; - } - if (!this.createdTimestampMs) { - this.createdTimestampMs = Date.now(); - dirty = true; - } - if (dirty) { - this.writeFile(); - } - } - - private writeFile(): void { - const state = JSON.stringify({ - serverId: this.serverId, - metricsEnabled: this.metricsEnabled, - name: this.name, - createdTimestampMs: this.createdTimestampMs - }); - this.configFile.writeFileSync(state); - } - - public getMetricsEnabled(): boolean { - return this.metricsEnabled; - } - - public setMetricsEnabled(newValue: boolean): void { - if (newValue !== this.metricsEnabled) { - this.metricsEnabled = newValue; - this.writeFile(); - } - } - - public getName(): string { - return this.name || 'Outline Server'; - } - - public setName(newValue: string): void { - if (newValue !== this.name) { - this.name = newValue; - this.writeFile(); - } - } +import * as json_config from '../infrastructure/json_config'; + +// Serialized format for the server config. +// WARNING: Renaming fields will break backwards-compatibility. +export interface ServerConfigJson { + serverId: string; + metricsEnabled: boolean; + name: string; + createdTimestampMs: number; +} - public getCreatedTimestampMs(): number { - return this.createdTimestampMs; +export function readServerConfig(filename: string): json_config.JsonConfig { + try { + const config = json_config.loadFileConfig(filename); + config.data().serverId = config.data().serverId || uuidv4(); + config.data().createdTimestampMs = config.data().createdTimestampMs || Date.now(); + config.data().metricsEnabled = config.data().metricsEnabled || false; + config.write(); + return config; + } catch (error) { + throw new Error(`Failed to read server config at ${filename}: ${error}`); } } diff --git a/src/shadowbox/server/metrics.spec.ts b/src/shadowbox/server/shared_metrics.spec.ts similarity index 96% rename from src/shadowbox/server/metrics.spec.ts rename to src/shadowbox/server/shared_metrics.spec.ts index 7ac9b0d4a..a84e17a7f 100644 --- a/src/shadowbox/server/metrics.spec.ts +++ b/src/shadowbox/server/shared_metrics.spec.ts @@ -15,9 +15,9 @@ import * as https from 'https'; import * as ip_location from '../infrastructure/ip_location'; -import {PerUserStats} from '../model/metrics'; +import {PerUserMetrics} from '../model/metrics'; -import * as metrics from './metrics'; +import * as shared_metrics from './shared_metrics'; const SERVER_ID = 'serverId'; const USER_ID_1 = 'userId1'; @@ -35,7 +35,7 @@ describe('getHourlyServerMetricsReport', () => { const lastHourUserStats = new Map(); lastHourUserStats.set(USER_ID_1, getPerUserStats([IP_ADDRESS_IN_US_1])); - metrics + shared_metrics .getHourlyServerMetricsReport( SERVER_ID, START_DATETIME, END_DATETIME, lastHourUserStats, new HardcodedIpLocationService()) @@ -51,7 +51,7 @@ describe('getHourlyServerMetricsReport', () => { lastHourUserStats.set(USER_ID_1, getPerUserStats([IP_ADDRESS_IN_US_1, IP_ADDRESS_IN_GB])); lastHourUserStats.set(USER_ID_2, getPerUserStats([IP_ADDRESS_IN_US_1])); - metrics + shared_metrics .getHourlyServerMetricsReport( SERVER_ID, START_DATETIME, END_DATETIME, lastHourUserStats, new HardcodedIpLocationService()) @@ -69,7 +69,7 @@ describe('getHourlyServerMetricsReport', () => { const lastHourUserStats = new Map(); lastHourUserStats.set(USER_ID_1, getPerUserStats([IP_ADDRESS_IN_US_1, IP_ADDRESS_IN_US_2])); - metrics + shared_metrics .getHourlyServerMetricsReport( SERVER_ID, START_DATETIME, END_DATETIME, lastHourUserStats, new HardcodedIpLocationService()) @@ -85,7 +85,7 @@ describe('getHourlyServerMetricsReport', () => { lastHourUserStats.set(USER_ID_1, getPerUserStats([IP_ADDRESS_IN_US_1])); lastHourUserStats.set(USER_ID_2, getPerUserStats([IP_ADDRESS_IN_US_2])); - metrics + shared_metrics .getHourlyServerMetricsReport( SERVER_ID, START_DATETIME, END_DATETIME, lastHourUserStats, new HardcodedIpLocationService()) @@ -104,7 +104,7 @@ describe('getHourlyServerMetricsReport', () => { USER_ID_1, getPerUserStats([IP_ADDRESS_IN_NORTH_KOREA, IP_ADDRESS_IN_US_1])); lastHourUserStats.set(USER_ID_2, getPerUserStats([IP_ADDRESS_IN_US_1])); - metrics + shared_metrics .getHourlyServerMetricsReport( SERVER_ID, START_DATETIME, END_DATETIME, lastHourUserStats, new HardcodedIpLocationService()) @@ -123,7 +123,7 @@ describe('getHourlyServerMetricsReport', () => { USER_ID_1, getPerUserStats([IP_ADDRESS_IN_NORTH_KOREA, IP_ADDRESS_IN_CUBA])); lastHourUserStats.set(USER_ID_2, getPerUserStats([IP_ADDRESS_IN_US_1])); - metrics + shared_metrics .getHourlyServerMetricsReport( SERVER_ID, START_DATETIME, END_DATETIME, lastHourUserStats, new HardcodedIpLocationService()) @@ -140,7 +140,7 @@ describe('getHourlyServerMetricsReport', () => { USER_ID_1, getPerUserStats([IP_ADDRESS_IN_NORTH_KOREA, IP_ADDRESS_IN_CUBA])); lastHourUserStats.set(USER_ID_2, getPerUserStats([IP_ADDRESS_IN_NORTH_KOREA])); - metrics + shared_metrics .getHourlyServerMetricsReport( SERVER_ID, START_DATETIME, END_DATETIME, lastHourUserStats, new HardcodedIpLocationService()) @@ -152,7 +152,7 @@ describe('getHourlyServerMetricsReport', () => { it('Does not propagate location service connection errors', (done) => { const lastHourUserStats = new Map(); lastHourUserStats.set('some_user_id', getPerUserStats(['127.0.0.1'])); - metrics + shared_metrics .getHourlyServerMetricsReport( SERVER_ID, START_DATETIME, END_DATETIME, lastHourUserStats, new FailConnectionIpLocationService()) @@ -169,7 +169,7 @@ describe('getHourlyServerMetricsReport', () => { it('Does not propagate location service promise rejection', (done) => { const lastHourUserStats = new Map(); lastHourUserStats.set('some_user_id', getPerUserStats(['127.0.0.1'])); - return metrics + return shared_metrics .getHourlyServerMetricsReport( SERVER_ID, START_DATETIME, END_DATETIME, lastHourUserStats, new AlwaysRejectIpLocationService()) @@ -185,7 +185,7 @@ describe('getHourlyServerMetricsReport', () => { }); }); -function getPerUserStats(ipAddresses: string[]): PerUserStats { +function getPerUserStats(ipAddresses: string[]): PerUserMetrics { return {bytesTransferred: 123, anonymizedIpAddresses: new Set(ipAddresses)}; } diff --git a/src/shadowbox/server/shared_metrics.ts b/src/shadowbox/server/shared_metrics.ts new file mode 100644 index 000000000..36d149114 --- /dev/null +++ b/src/shadowbox/server/shared_metrics.ts @@ -0,0 +1,280 @@ +// Copyright 2018 The Outline Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import * as events from 'events'; + +import * as follow_redirects from '../infrastructure/follow_redirects'; +import * as ip_location from '../infrastructure/ip_location'; +import {JsonConfig} from '../infrastructure/json_config'; +import * as logging from '../infrastructure/logging'; +import {AccessKeyId} from '../model/access_key'; +import {PerUserMetrics} from '../model/metrics'; +import {LastHourMetricsReadyCallback} from '../model/metrics'; + +import * as ip_util from './ip_util'; +import {ServerConfigJson} from './server_config'; + +// Serialized format for the shared metrics. +// WARNING: Renaming fields will break backwards-compatibility. +export interface SharedMetricsJson { + startTimestamp?: number; + // TODO: Save the countries rather than anonymized IPs. There's no point in keeping the IPs. + lastHourUserStatsObj?: + {[accessKeyId: string]: {bytesTransferred: number; anonymizedIpAddresses: string[];}}; +} + +const LAST_HOUR_METRICS_READY_EVENT = 'lastHourMetricsReady'; + +// Keeps track of the connection metrics per user, since the startDatetime. +// This is reported to the Outline team if the admin opts-in. +export class SharedMetrics { + private eventEmitter = new events.EventEmitter(); + + // Date+time at which we started recording connection metrics, e.g. + // in case this object is constructed from data written to disk. + public startDatetime: Date; + + // Map from the metrics AccessKeyId to metrics (bytes transferred, IP addresses). + public lastHourUserMetrics: Map; + + constructor( + private config: JsonConfig, + private serverConfig: JsonConfig, metricsUrl: string, + ipLocationService: ip_location.IpLocationService) { + const serializedObject = this.config.data(); + this.startDatetime = + serializedObject.startTimestamp ? new Date(serializedObject.startTimestamp) : new Date(); + + this.lastHourUserMetrics = new Map(); + if (serializedObject.lastHourUserStatsObj) { + Object.keys(serializedObject.lastHourUserStatsObj).map((userId) => { + const perUserStatsObj = serializedObject.lastHourUserStatsObj[userId]; + this.lastHourUserMetrics.set(userId, { + bytesTransferred: perUserStatsObj.bytesTransferred, + anonymizedIpAddresses: new Set(perUserStatsObj.anonymizedIpAddresses) + }); + }); + } + + this.onLastHourMetricsReady((startDatetime, endDatetime, lastHourUserStats) => { + if (!this.serverConfig.data().metricsEnabled) { + return; + } + getHourlyServerMetricsReport( + this.serverConfig.data().serverId, startDatetime, endDatetime, lastHourUserStats, + ipLocationService) + .then((report) => { + if (report) { + postHourlyServerMetricsReports(report, metricsUrl); + } + }); + }); + + // Set hourly metrics report interval + setHourlyInterval(this.generateHourlyReport.bind(this)); + } + + // CONSIDER: accepting hashedIpAddresses, which can be persisted to disk + // and reported to the metrics server (to approximate number of devices per userId). + recordBytesTransferred(userId: AccessKeyId, numBytes: number, ipAddresses: string[]) { + const perUserMetrics = this.lastHourUserMetrics.get(userId) || + {bytesTransferred: 0, anonymizedIpAddresses: new Set()}; + perUserMetrics.bytesTransferred += numBytes; + const anonymizedIpAddresses = getAnonymizedAndDedupedIpAddresses(ipAddresses); + for (const ip of anonymizedIpAddresses) { + perUserMetrics.anonymizedIpAddresses.add(ip); + } + this.lastHourUserMetrics.set(userId, perUserMetrics); + this.toJson(this.config.data()); + this.config.write(); + } + + reset(): void { + this.lastHourUserMetrics = new Map(); + this.startDatetime = new Date(); + this.toJson(this.config.data()); + this.config.write(); + } + + private onLastHourMetricsReady(callback: LastHourMetricsReadyCallback) { + this.eventEmitter.on(LAST_HOUR_METRICS_READY_EVENT, callback); + + // Check if an hourly metrics report is already due (e.g. if server was shutdown over an + // hour ago and just restarted). + if (getHoursSinceDatetime(this.startDatetime) >= 1) { + this.generateHourlyReport(); + } + } + + // Returns the state of this object, e.g. + // {"startTimestamp":1502896650353,"lastHourUserStatsObj":{"0":{"bytesTransferred":100,"anonymizedIpAddresses":["2620:0:1003:0:0:0:0:0","5.2.79.0"]}}} + private toJson(target: SharedMetricsJson) { + // lastHourUserStats is a Map containing Set structures. Convert to an object + // with array values. + const lastHourUserStatsObj = {}; + this.lastHourUserMetrics.forEach((perUserStats, userId) => { + lastHourUserStatsObj[userId] = { + bytesTransferred: perUserStats.bytesTransferred, + anonymizedIpAddresses: [...perUserStats.anonymizedIpAddresses] + }; + }); + target.startTimestamp = this.startDatetime.getTime(); + target.lastHourUserStatsObj = lastHourUserStatsObj; + return {startTimestamp: this.startDatetime.getTime(), lastHourUserStatsObj}; + } + + private generateHourlyReport(): void { + if (this.lastHourUserMetrics.size === 0) { + // No connection metrics to report. + return; + } + + this.eventEmitter.emit( + LAST_HOUR_METRICS_READY_EVENT, this.startDatetime, + new Date(), // endDatetime is the current date and time. + this.lastHourUserMetrics); + + // Reset connection metrics to begin recording the next hour. + this.reset(); + } +} + +function getAnonymizedAndDedupedIpAddresses(ipAddresses: string[]): Set { + const s = new Set(); + for (const ip of ipAddresses) { + try { + s.add(ip_util.anonymizeIp(ip)); + } catch (err) { + logging.error('error anonymizing IP address: ' + ip + ', ' + err); + } + } + return s; +} + +export function getHourlyServerMetricsReport( + serverId: string, startDatetime: Date, endDatetime: Date, + lastHourUserMetrics: Map, + ipLocationService: ip_location.IpLocationService): Promise { + if (lastHourUserMetrics.size === 0) { + // Metrics are empty, no need to post a report + return Promise.resolve(null); + } + // convert lastHourUserStats to an array HourlyUserMetricsReport + const userReportPromises = []; + lastHourUserMetrics.forEach((perUserMetrics, userId) => { + userReportPromises.push(getHourlyUserMetricsReport(userId, perUserMetrics, ipLocationService)); + }); + return Promise.all(userReportPromises).then((userReports: HourlyUserMetricsReport[]) => { + // Remove any userReports containing sanctioned countries, and return + // null if no reports remain with un-sanctioned countries. + userReports = getWithoutSanctionedReports(userReports); + if (userReports.length === 0) { + return null; + } + return { + serverId, + startUtcMs: startDatetime.getTime(), + endUtcMs: endDatetime.getTime(), + userReports + }; + }); +} + +export function postHourlyServerMetricsReports( + report: HourlyServerMetricsReport, metricsUrl: string) { + const options = { + url: metricsUrl, + headers: {'Content-Type': 'application/json'}, + method: 'POST', + body: JSON.stringify(report) + }; + logging.info('Posting metrics: ' + JSON.stringify(options)); + return follow_redirects.requestFollowRedirectsWithSameMethodAndBody( + options, (error, response, body) => { + if (error) { + logging.error(`Error posting metrics: ${error}`); + return; + } + logging.info('Metrics server responded with status ' + response.statusCode); + }); +} + +interface HourlyServerMetricsReport { + serverId: string; + startUtcMs: number; + endUtcMs: number; + userReports: HourlyUserMetricsReport[]; +} + +interface HourlyUserMetricsReport { + userId: string; + countries: string[]; + bytesTransferred: number; +} + +function getHourlyUserMetricsReport( + userId: AccessKeyId, perUserMetrics: PerUserMetrics, + ipLocationService: ip_location.IpLocationService): Promise { + const countryPromises = []; + for (const ip of perUserMetrics.anonymizedIpAddresses) { + const countryPromise = ipLocationService.countryForIp(ip).catch((e) => { + logging.warn(`Failed countryForIp call: ${e}`); + return 'ERROR'; + }); + countryPromises.push(countryPromise); + } + return Promise.all(countryPromises).then((countries: string[]) => { + return { + userId, + bytesTransferred: perUserMetrics.bytesTransferred, + countries: getWithoutDuplicates(countries) + }; + }); +} + +// Return an array with the duplicate elements removed. +function getWithoutDuplicates(a: T[]): T[] { + return [...new Set(a)]; +} + +function getWithoutSanctionedReports(userReports: HourlyUserMetricsReport[]): + HourlyUserMetricsReport[] { + const sanctionedCountries = ['CU', 'IR', 'KP', 'SY']; + const filteredReports = []; + for (const userReport of userReports) { + userReport.countries = userReport.countries.filter((country) => { + return sanctionedCountries.indexOf(country) === -1; + }); + if (userReport.countries.length > 0) { + filteredReports.push(userReport); + } + } + return filteredReports; +} + +const MS_PER_HOUR = 60 * 60 * 1000; + +function setHourlyInterval(callback: Function) { + const msUntilNextHour = MS_PER_HOUR - (Date.now() % MS_PER_HOUR); + setTimeout(() => { + setInterval(callback, MS_PER_HOUR); + callback(); + }, msUntilNextHour); +} + +// Returns the floating-point number of hours passed since the specified date. +function getHoursSinceDatetime(d: Date): number { + const deltaMs = Date.now() - d.getTime(); + return deltaMs / (MS_PER_HOUR); +}