From cb624cd39284773724fd81b36dce8975fad1e324 Mon Sep 17 00:00:00 2001 From: alalamav <2132122+alalamav@users.noreply.github.com> Date: Wed, 28 Aug 2019 11:11:50 -0400 Subject: [PATCH] Access key limits (#475) --- src/shadowbox/README.md | 10 +- src/shadowbox/model/access_key.ts | 43 ++- src/shadowbox/model/errors.ts | 19 +- src/shadowbox/server/api.yml | 32 +- src/shadowbox/server/manager_service.spec.ts | 155 +++++---- src/shadowbox/server/manager_service.ts | 67 ++-- .../server/server_access_key.spec.ts | 313 +++++++++--------- src/shadowbox/server/server_access_key.ts | 167 ++++------ 8 files changed, 405 insertions(+), 401 deletions(-) diff --git a/src/shadowbox/README.md b/src/shadowbox/README.md index b4a4fae3..03a57488 100644 --- a/src/shadowbox/README.md +++ b/src/shadowbox/README.md @@ -119,15 +119,15 @@ Remove an access key curl --insecure -X DELETE $API_URL/access-keys/2 ``` -Set an access key quota -(e.g. limit outbound data transfer for access key 2 to 1MB over a 24 hour sliding window) +Set an access key data limit +(e.g. limit outbound data transfer for access key 2 to 1MB over a 24 hour sliding timeframe) ``` -curl -v --insecure -X PUT -H "Content-Type: application/json" -d '{"quota": {"data": {"bytes": 1000}, "window": {"hours": 1}}}' $API_URL/access-keys/2/quota +curl -v --insecure -X PUT -H "Content-Type: application/json" -d '{"limit": {"data": {"bytes": 1000}, "timeframe": {"hours": 1}}}' $API_URL/access-keys/2/data-limit ``` -Remove an access key quota +Remove an access key data limit ``` -curl -v --insecure -X DELETE $API_URL/access-keys/2/quota +curl -v --insecure -X DELETE $API_URL/access-keys/2/data-limit ``` ## Testing diff --git a/src/shadowbox/model/access_key.ts b/src/shadowbox/model/access_key.ts index 21dfdcae..8a6d0f5d 100644 --- a/src/shadowbox/model/access_key.ts +++ b/src/shadowbox/model/access_key.ts @@ -27,19 +27,19 @@ export interface ProxyParams { readonly password: string; } -// Parameters needed to limit access key data usage over a sliding window. -export interface AccessKeyQuota { +// Parameters needed to limit access key data usage over a sliding timeframe. +export interface AccessKeyDataLimit { // The allowed metered data transfer measured in bytes. readonly data: {bytes: number}; - // The sliding window size in hours. - readonly window: {hours: number}; + // The sliding timeframe size in hours. + readonly timeframe: {hours: number}; } -// Parameters needed to enforce an access key data transfer quota. -export interface AccessKeyQuotaUsage { - // Data transfer quota on this access key. - readonly quota: AccessKeyQuota; - // Data transferred by this access key over the quota window. +// Parameters needed to enforce an access key data transfer limit. +export interface AccessKeyDataLimitUsage { + // Data transfer limit on this access key. + readonly limit: AccessKeyDataLimit; + // Data transferred by this access key over the limit timeframe. readonly usage: {bytes: number}; } @@ -53,28 +53,27 @@ export interface AccessKey { readonly metricsId: AccessKeyMetricsId; // Parameters to access the proxy readonly proxyParams: ProxyParams; - // Admin-controlled, data transfer quota for this access key. Unlimited if unset. - readonly quotaUsage?: AccessKeyQuotaUsage; - // Returns whether the access key has exceeded its data transfer quota. - isOverQuota(): boolean; + // Admin-controlled, data transfer limit for this access key. Unlimited if unset. + readonly dataLimitUsage?: AccessKeyDataLimitUsage; + // Returns whether the access key has exceeded its data transfer limit. + isOverDataLimit(): boolean; } export interface AccessKeyRepository { // Creates a new access key. Parameters are chosen automatically. createNewAccessKey(): Promise; - // Removes the access key given its id. Returns true if successful. - removeAccessKey(id: AccessKeyId): boolean; + // Removes the access key given its id. Throws on failure. + removeAccessKey(id: AccessKeyId); // Lists all existing access keys listAccessKeys(): AccessKey[]; // Changes the port for new access keys. setPortForNewAccessKeys(port: number): Promise; - // Apply the specified update to the specified access key. - // Returns true if successful. - renameAccessKey(id: AccessKeyId, name: string): boolean; + // Apply the specified update to the specified access key. Throws on failure. + renameAccessKey(id: AccessKeyId, name: string): void; // Gets the metrics id for a given Access Key. getMetricsId(id: AccessKeyId): AccessKeyMetricsId|undefined; - // Sets the transfer quota for the specified access key. Returns true if successful. - setAccessKeyQuota(id: AccessKeyId, quota: AccessKeyQuota): Promise; - // Clears the transfer quota for the specified access key. Returns true if successful. - removeAccessKeyQuota(id: AccessKeyId): Promise; + // Sets the transfer limit for the specified access key. Throws on failure. + setAccessKeyDataLimit(id: AccessKeyId, limit: AccessKeyDataLimit): Promise; + // Clears the transfer limit for the specified access key. Throws on failure. + removeAccessKeyDataLimit(id: AccessKeyId): Promise; } diff --git a/src/shadowbox/model/errors.ts b/src/shadowbox/model/errors.ts index 90aafb4e..f9c2d9aa 100644 --- a/src/shadowbox/model/errors.ts +++ b/src/shadowbox/model/errors.ts @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -class ShadowboxError extends Error { +class OutlineError extends Error { constructor(message: string) { super(message); // https://www.typescriptlang.org/docs/handbook/release-notes/typescript-2-2.html#support-for-newtarget @@ -20,15 +20,28 @@ class ShadowboxError extends Error { } } -export class InvalidPortNumber extends ShadowboxError { +export class InvalidPortNumber extends OutlineError { // Since this is the error when a non-numeric value is passed to `port`, it takes type `string`. constructor(public port: string) { super(port); } } -export class PortUnavailable extends ShadowboxError { +export class PortUnavailable extends OutlineError { constructor(public port: number) { super(port.toString()); } } + +export class AccessKeyNotFound extends OutlineError { + constructor(accessKeyId?: string) { + super(`Access key "${accessKeyId}" not found`); + } +} + +export class InvalidAccessKeyDataLimit extends OutlineError { + constructor() { + super( + 'Must provide a limit value with positive integer values for "data.bytes" and "timeframe.hours"'); + } +} diff --git a/src/shadowbox/server/api.yml b/src/shadowbox/server/api.yml index 907d2729..a71bf280 100644 --- a/src/shadowbox/server/api.yml +++ b/src/shadowbox/server/api.yml @@ -52,7 +52,7 @@ paths: description: The requested port wasn't an integer from 1 through 65535, or the request had no port parameter. '409': description: The requested port was already in use by another service. - + /name: put: description: Renames the server @@ -174,17 +174,17 @@ paths: description: Access key renamed successfully '404': description: Access key inexistent - /access-keys/{id}/quota: + /access-keys/{id}/data-limit: put: - description: Sets an access key data transfer quota + description: Sets an access key data transfer limit tags: - Access Key - - Quota + - Limit parameters: - name: id in: path required: true - description: The id of the access key to set a quota + description: The id of the access key to set a limit schema: type: string requestBody: @@ -192,30 +192,30 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/AccessKeyQuota" + $ref: "#/components/schemas/AccessKeyDataLimit" examples: '0': - value: "{quotaBytes: 1000000, windowHours: 24}" + value: "{limitBytes: 1000000, timeframeHours: 24}" responses: '204': - description: Access key quota set successfully + description: Access key limit set successfully '404': description: Access key inexistent delete: - description: Removes an access key data transfer quota, lifting data transfer restrictions on the key. + description: Removes an access key data transfer limit, lifting data transfer restrictions on the key. tags: - Access Key - - Quota + - Limit parameters: - name: id in: path required: true - description: The id of the access key to delete a quota + description: The id of the access key to delete a limit schema: type: string responses: '204': - description: Access key quota deleted successfully. + description: Access key limit deleted successfully. '404': description: Access key inexistent /metrics/transfer: @@ -292,14 +292,14 @@ components: type: number portForNewAccessKeys: type: integer - AccessKeyQuota: + AccessKeyDataLimit: properties: data: type: object properties: bytes: type: integer - window: + timeframe: type: object properties: hours: @@ -320,5 +320,5 @@ components: type: string accessUrl: type: string - quota: - $ref: "#/components/schemas/AccessKeyQuota" + limit: + $ref: "#/components/schemas/AccessKeyDataLimit" diff --git a/src/shadowbox/server/manager_service.spec.ts b/src/shadowbox/server/manager_service.spec.ts index 66b516c6..5f0da5e2 100644 --- a/src/shadowbox/server/manager_service.spec.ts +++ b/src/shadowbox/server/manager_service.spec.ts @@ -15,7 +15,7 @@ import * as net from 'net'; import {InMemoryConfig} from '../infrastructure/json_config'; -import {AccessKey, AccessKeyQuota, AccessKeyRepository} from '../model/access_key'; +import {AccessKey, AccessKeyDataLimit, AccessKeyRepository} from '../model/access_key'; import {ShadowsocksManagerService} from './manager_service'; import {FakePrometheusClient, FakeShadowsocksServer} from './mocks/mocks'; @@ -27,8 +27,10 @@ interface ServerInfo { name: string; } -const newPort = 12345; -const oldPort = 54321; +const NEW_PORT = 12345; +const OLD_PORT = 54321; +const EXPECTED_ACCESS_KEY_PROPERTIES = + ['id', 'name', 'password', 'port', 'method', 'accessUrl', 'limit'].sort(); describe('ShadowsocksManagerService', () => { // After processing the response callback, we should set @@ -117,6 +119,30 @@ describe('ShadowsocksManagerService', () => { service.listAccessKeys({params: {}}, res, done); }); }); + it('lists access keys with expected properties', async (done) => { + const repo = getAccessKeyRepository(); + const service = new ShadowsocksManagerService('', null, repo, null, null); + const accessKey = await repo.createNewAccessKey(); + await repo.createNewAccessKey(); + const limit = {data: {bytes: 10000}, timeframe: {hours: 48}}; + await repo.setAccessKeyDataLimit(accessKey.id, limit); + const accessKeyName = 'new name'; + await repo.renameAccessKey(accessKey.id, accessKeyName); + const res = { + send: (httpCode, data) => { + expect(httpCode).toEqual(200); + expect(data.accessKeys.length).toEqual(2); + const serviceAccessKey1 = data.accessKeys[0]; + const serviceAccessKey2 = data.accessKeys[1]; + expect(Object.keys(serviceAccessKey1).sort()).toEqual(EXPECTED_ACCESS_KEY_PROPERTIES); + expect(Object.keys(serviceAccessKey2).sort()).toEqual(EXPECTED_ACCESS_KEY_PROPERTIES); + expect(serviceAccessKey1.name).toEqual(accessKeyName); + expect(serviceAccessKey1.limit).toEqual(limit); + responseProcessed = true; // required for afterEach to pass. + } + }; + service.listAccessKeys({params: {}}, res, done); + }); }); describe('createNewAccessKey', () => { @@ -128,9 +154,7 @@ describe('ShadowsocksManagerService', () => { const res = { send: (httpCode, data) => { expect(httpCode).toEqual(201); - const expectedProperties = - ['id', 'name', 'password', 'port', 'method', 'accessUrl', 'quota']; - expect(Object.keys(data).sort()).toEqual(expectedProperties.sort()); + expect(Object.keys(data).sort()).toEqual(EXPECTED_ACCESS_KEY_PROPERTIES); responseProcessed = true; // required for afterEach to pass. } }; @@ -161,10 +185,10 @@ describe('ShadowsocksManagerService', () => { expect(httpCode).toEqual(204); } }; - await service.setPortForNewAccessKeys({params: {port: newPort}}, res, () => {}); + await service.setPortForNewAccessKeys({params: {port: NEW_PORT}}, res, () => {}); const newKey = await repo.createNewAccessKey(); - expect(newKey.proxyParams.portNumber).toEqual(newPort); - expect(oldKey.proxyParams.portNumber).not.toEqual(newPort); + expect(newKey.proxyParams.portNumber).toEqual(NEW_PORT); + expect(oldKey.proxyParams.portNumber).not.toEqual(NEW_PORT); responseProcessed = true; done(); }); @@ -177,11 +201,11 @@ describe('ShadowsocksManagerService', () => { const res = { send: (httpCode) => { expect(httpCode).toEqual(204); - expect(serverConfig.data().portForNewAccessKeys).toEqual(newPort); + expect(serverConfig.data().portForNewAccessKeys).toEqual(NEW_PORT); responseProcessed = true; } }; - await service.setPortForNewAccessKeys({params: {port: newPort}}, res, done); + await service.setPortForNewAccessKeys({params: {port: NEW_PORT}}, res, done); }); it('rejects invalid port numbers', async (done) => { @@ -230,8 +254,8 @@ describe('ShadowsocksManagerService', () => { }; const server = new net.Server(); - server.listen(newPort, async () => { - await service.setPortForNewAccessKeys({params: {port: newPort}}, res, next); + server.listen(NEW_PORT, async () => { + await service.setPortForNewAccessKeys({params: {port: NEW_PORT}}, res, next); }); }); @@ -241,9 +265,7 @@ describe('ShadowsocksManagerService', () => { const service = new ShadowsocksManagerService('name', serverConfig, repo, null, null); await service.createNewAccessKey({params: {}}, {send: () => {}}, () => {}); - - await service.setPortForNewAccessKeys({params: {port: newPort}}, {send: () => {}}, () => {}); - + await service.setPortForNewAccessKeys({params: {port: NEW_PORT}}, {send: () => {}}, () => {}); const res = { send: (httpCode) => { expect(httpCode).toEqual(204); @@ -252,8 +274,8 @@ describe('ShadowsocksManagerService', () => { }; const firstKeyConnection = new net.Server(); - firstKeyConnection.listen(oldPort, async () => { - await service.setPortForNewAccessKeys({params: {port: oldPort}}, res, () => {}); + firstKeyConnection.listen(OLD_PORT, async () => { + await service.setPortForNewAccessKeys({params: {port: OLD_PORT}}, res, () => {}); firstKeyConnection.close(); done(); }); @@ -265,7 +287,6 @@ describe('ShadowsocksManagerService', () => { const service = new ShadowsocksManagerService('name', serverConfig, repo, null, null); const noPort = {params: {}}; - const res = { send: (httpCode) => { fail( @@ -365,65 +386,56 @@ describe('ShadowsocksManagerService', () => { }); }); - describe('setAccessKeyQuota', () => { - it('sets access key quota', async (done) => { + describe('setAccessKeyDataLimit', () => { + it('sets access key limit', async (done) => { const repo = getAccessKeyRepository(); const service = new ShadowsocksManagerService('default name', null, repo, null, null); const accessKey = await repo.createNewAccessKey(); - expect(accessKey.quotaUsage).toBeUndefined(); - expect(accessKey.isOverQuota()).toBeFalsy(); - const quota = {data: {bytes: 10000}, window: {hours: 48}}; + expect(accessKey.dataLimitUsage).toBeUndefined(); + expect(accessKey.isOverDataLimit()).toBeFalsy(); + const limit = {data: {bytes: 10000}, timeframe: {hours: 48}}; const res = { send: (httpCode, data) => { expect(httpCode).toEqual(204); - const accessKey = getFirstAccessKey(repo); - expect(accessKey.quotaUsage.quota).toEqual(quota); - expect(accessKey.isOverQuota()).toBeFalsy(); + expect(accessKey.dataLimitUsage.limit).toEqual(limit); + expect(accessKey.isOverDataLimit()).toBeFalsy(); responseProcessed = true; // required for afterEach to pass. } }; - service.setAccessKeyQuota({params: {id: accessKey.id, quota}}, res, done); + service.setAccessKeyDataLimit({params: {id: accessKey.id, limit}}, res, done); }); - it('returns 409 when quota is missing values', async (done) => { + it('returns 400 when limit is missing values', async (done) => { const repo = getAccessKeyRepository(); const service = new ShadowsocksManagerService('default name', null, repo, null, null); const accessKey = await repo.createNewAccessKey(); - let quota = {data: {bytes: 1}} as AccessKeyQuota; + let limit = {data: {bytes: 1}} as AccessKeyDataLimit; const res = {send: (httpCode, data) => {}}; - await service.setAccessKeyQuota({params: {id: accessKey.id, quota}}, res, (error) => { - expect(error.statusCode).toEqual(409); + await service.setAccessKeyDataLimit({params: {id: accessKey.id, limit}}, res, (error) => { + expect(error.statusCode).toEqual(400); }); - quota = {window: {}} as AccessKeyQuota; - await service.setAccessKeyQuota({params: {id: accessKey.id, quota}}, res, (error) => { - expect(error.statusCode).toEqual(409); + limit = {timeframe: {}} as AccessKeyDataLimit; + await service.setAccessKeyDataLimit({params: {id: accessKey.id, limit}}, res, (error) => { + expect(error.statusCode).toEqual(400); }); - quota = {window: {hours: 1}} as AccessKeyQuota; - service.setAccessKeyQuota({params: {id: accessKey.id, quota}}, res, (error) => { - expect(error.statusCode).toEqual(409); + limit = {timeframe: {hours: 1}} as AccessKeyDataLimit; + service.setAccessKeyDataLimit({params: {id: accessKey.id, limit}}, res, (error) => { + expect(error.statusCode).toEqual(400); responseProcessed = true; // required for afterEach to pass. done(); }); }); - it('returns 409 when quota bytes is negative', async (done) => { + it('returns 400 when limit has negative values', async (done) => { const repo = getAccessKeyRepository(); const service = new ShadowsocksManagerService('default name', null, repo, null, null); const accessKey = await repo.createNewAccessKey(); - const quota = {data: {bytes: -1}, window: {hours: 24}}; + let limit = {data: {bytes: -1}, timeframe: {hours: 24}}; const res = {send: (httpCode, data) => {}}; - service.setAccessKeyQuota({params: {id: accessKey.id, quota}}, res, (error) => { - expect(error.statusCode).toEqual(409); - responseProcessed = true; // required for afterEach to pass. - done(); + await service.setAccessKeyDataLimit({params: {id: accessKey.id, limit}}, res, (error) => { + expect(error.statusCode).toEqual(400); }); - }); - it('returns 409 when quota window is negative', async (done) => { - const repo = getAccessKeyRepository(); - const service = new ShadowsocksManagerService('default name', null, repo, null, null); - const accessKey = await repo.createNewAccessKey(); - const quota = {data: {bytes: 1000}, window: {hours: -24}}; - const res = {send: (httpCode, data) => {}}; - service.setAccessKeyQuota({params: {id: accessKey.id, quota}}, res, (error) => { - expect(error.statusCode).toEqual(409); + limit = {data: {bytes: 1000}, timeframe: {hours: -24}}; + service.setAccessKeyDataLimit({params: {id: accessKey.id, limit}}, res, (error) => { + expect(error.statusCode).toEqual(400); responseProcessed = true; // required for afterEach to pass. done(); }); @@ -431,9 +443,9 @@ describe('ShadowsocksManagerService', () => { it('returns 404 when the access key is not found', async (done) => { const repo = getAccessKeyRepository(); const service = new ShadowsocksManagerService('default name', null, repo, null, null); - const quota = {data: {bytes: 1000}, window: {hours: 24}}; + const limit = {data: {bytes: 1000}, timeframe: {hours: 24}}; const res = {send: (httpCode, data) => {}}; - service.setAccessKeyQuota({params: {id: 'doesnotexist', quota}}, res, (error) => { + service.setAccessKeyDataLimit({params: {id: 'doesnotexist', limit}}, res, (error) => { expect(error.statusCode).toEqual(404); responseProcessed = true; // required for afterEach to pass. done(); @@ -441,12 +453,12 @@ describe('ShadowsocksManagerService', () => { }); it('returns 500 when the repository throws an exception', async (done) => { const repo = getAccessKeyRepository(); - spyOn(repo, 'setAccessKeyQuota').and.throwError('cannot write to disk'); + spyOn(repo, 'setAccessKeyDataLimit').and.throwError('cannot write to disk'); const service = new ShadowsocksManagerService('default name', null, repo, null, null); const accessKey = await repo.createNewAccessKey(); - const quota = {data: {bytes: 10000}, window: {hours: 48}}; + const limit = {data: {bytes: 10000}, timeframe: {hours: 48}}; const res = {send: (httpCode, data) => {}}; - service.setAccessKeyQuota({params: {id: accessKey.id, quota}}, res, (error) => { + service.setAccessKeyDataLimit({params: {id: accessKey.id, limit}}, res, (error) => { expect(error.statusCode).toEqual(500); responseProcessed = true; // required for afterEach to pass. done(); @@ -454,31 +466,30 @@ describe('ShadowsocksManagerService', () => { }); }); - describe('removeAccessKeyQuota', () => { - it('clears access key quota', async (done) => { + describe('removeAccessKeyDataLimit', () => { + it('clears access key limit', async (done) => { const repo = getAccessKeyRepository(); const service = new ShadowsocksManagerService('default name', null, repo, null, null); - const quota = {data: {bytes: 10000}, window: {hours: 48}}; + const limit = {data: {bytes: 10000}, timeframe: {hours: 48}}; const accessKey = await repo.createNewAccessKey(); - repo.setAccessKeyQuota(accessKey.id, quota); - expect(accessKey.quotaUsage.quota).toEqual(quota); - expect(accessKey.quotaUsage.usage.bytes).toEqual(0); + await repo.setAccessKeyDataLimit(accessKey.id, limit); + expect(accessKey.dataLimitUsage.limit).toEqual(limit); + expect(accessKey.dataLimitUsage.usage.bytes).toEqual(0); const res = { send: (httpCode, data) => { expect(httpCode).toEqual(204); - const accessKey = getFirstAccessKey(repo); - expect(accessKey.quotaUsage).toBeUndefined(); - expect(accessKey.isOverQuota()).toBeFalsy(); + expect(accessKey.dataLimitUsage).toBeUndefined(); + expect(accessKey.isOverDataLimit()).toBeFalsy(); responseProcessed = true; // required for afterEach to pass. } }; - service.removeAccessKeyQuota({params: {id: accessKey.id}}, res, done); + service.removeAccessKeyDataLimit({params: {id: accessKey.id}}, res, done); }); it('returns 404 when the access key is not found', async (done) => { const repo = getAccessKeyRepository(); const service = new ShadowsocksManagerService('default name', null, repo, null, null); const res = {send: (httpCode, data) => {}}; - service.removeAccessKeyQuota({params: {id: 'doesnotexist'}}, res, (error) => { + service.removeAccessKeyDataLimit({params: {id: 'doesnotexist'}}, res, (error) => { expect(error.statusCode).toEqual(404); responseProcessed = true; // required for afterEach to pass. done(); @@ -486,11 +497,11 @@ describe('ShadowsocksManagerService', () => { }); it('returns 500 when the repository throws an exception', async (done) => { const repo = getAccessKeyRepository(); - spyOn(repo, 'removeAccessKeyQuota').and.throwError('cannot write to disk'); + spyOn(repo, 'removeAccessKeyDataLimit').and.throwError('cannot write to disk'); const service = new ShadowsocksManagerService('default name', null, repo, null, null); const accessKey = await repo.createNewAccessKey(); const res = {send: (httpCode, data) => {}}; - service.removeAccessKeyQuota({params: {id: accessKey.id}}, res, (error) => { + service.removeAccessKeyDataLimit({params: {id: accessKey.id}}, res, (error) => { expect(error.statusCode).toEqual(500); responseProcessed = true; // required for afterEach to pass. done(); @@ -566,6 +577,6 @@ function fakeSharedMetricsReporter(): SharedMetricsPublisher { function getAccessKeyRepository(): AccessKeyRepository { return new ServerAccessKeyRepository( - oldPort, 'hostname', new InMemoryConfig({accessKeys: [], nextId: 0}), + OLD_PORT, 'hostname', new InMemoryConfig({accessKeys: [], nextId: 0}), new FakeShadowsocksServer(), new FakePrometheusClient({})); } diff --git a/src/shadowbox/server/manager_service.ts b/src/shadowbox/server/manager_service.ts index 09f1d8af..4d81cb5f 100644 --- a/src/shadowbox/server/manager_service.ts +++ b/src/shadowbox/server/manager_service.ts @@ -17,7 +17,7 @@ import {makeConfig, SIP002_URI} from 'ShadowsocksConfig/shadowsocks_config'; import {JsonConfig} from '../infrastructure/json_config'; import * as logging from '../infrastructure/logging'; -import {AccessKey, AccessKeyQuota, AccessKeyRepository} from '../model/access_key'; +import {AccessKey, AccessKeyDataLimit, AccessKeyRepository} from '../model/access_key'; import * as errors from '../model/errors'; import {ManagerMetrics} from './manager_metrics'; @@ -42,7 +42,7 @@ function accessKeyToJson(accessKey: AccessKey) { password: accessKey.proxyParams.password, outline: 1, })), - quota: accessKey.quotaUsage ? accessKey.quotaUsage.quota : undefined + limit: accessKey.dataLimitUsage ? accessKey.dataLimitUsage.limit : undefined }; } @@ -52,7 +52,7 @@ interface RequestParams { id?: string; name?: string; metricsEnabled?: boolean; - quota?: AccessKeyQuota; + limit?: AccessKeyDataLimit; port?: number; } interface RequestType { @@ -80,8 +80,10 @@ export function bindService( apiServer.del(`${apiPrefix}/access-keys/:id`, service.removeAccessKey.bind(service)); apiServer.put(`${apiPrefix}/access-keys/:id/name`, service.renameAccessKey.bind(service)); - apiServer.put(`${apiPrefix}/access-keys/:id/quota`, service.setAccessKeyQuota.bind(service)); - apiServer.del(`${apiPrefix}/access-keys/:id/quota`, service.removeAccessKeyQuota.bind(service)); + apiServer.put( + `${apiPrefix}/access-keys/:id/data-limit`, service.setAccessKeyDataLimit.bind(service)); + apiServer.del( + `${apiPrefix}/access-keys/:id/data-limit`, service.removeAccessKeyDataLimit.bind(service)); apiServer.get(`${apiPrefix}/metrics/transfer`, service.getDataUsage.bind(service)); apiServer.get(`${apiPrefix}/metrics/enabled`, service.getShareMetrics.bind(service)); @@ -156,7 +158,7 @@ export class ShadowsocksManagerService { public async setPortForNewAccessKeys(req: RequestType, res: ResponseType, next: restify.Next): Promise { try { - logging.debug(`setPort[ForNewAccessKeys request ${JSON.stringify(req.params)}`); + logging.debug(`setPortForNewAccessKeys request ${JSON.stringify(req.params)}`); if (!req.params.port) { return next( new restify.MissingParameterError({statusCode: 400}, 'Parameter `port` is missing')); @@ -168,7 +170,6 @@ export class ShadowsocksManagerService { {statusCode: 400}, `Expected an numeric port, instead got ${port} of type ${typeof port}`)); } - await this.accessKeys.setPortForNewAccessKeys(port); this.serverConfig.data().portForNewAccessKeys = port; this.serverConfig.write(); @@ -190,13 +191,14 @@ export class ShadowsocksManagerService { try { logging.debug(`removeAccessKey request ${JSON.stringify(req.params)}`); const accessKeyId = req.params.id; - if (!this.accessKeys.removeAccessKey(accessKeyId)) { - return next(new restify.NotFoundError(`No access key found with id ${accessKeyId}`)); - } + this.accessKeys.removeAccessKey(accessKeyId); res.send(HttpSuccess.NO_CONTENT); return next(); } catch (error) { logging.error(error); + if (error instanceof errors.AccessKeyNotFound) { + return next(new restify.NotFoundError(error.message)); + } return next(new restify.InternalServerError()); } } @@ -205,54 +207,53 @@ export class ShadowsocksManagerService { try { logging.debug(`renameAccessKey request ${JSON.stringify(req.params)}`); const accessKeyId = req.params.id; - if (!this.accessKeys.renameAccessKey(accessKeyId, req.params.name)) { - return next(new restify.NotFoundError(`No access key found with id ${accessKeyId}`)); - } + this.accessKeys.renameAccessKey(accessKeyId, req.params.name); res.send(HttpSuccess.NO_CONTENT); return next(); } catch (error) { logging.error(error); + if (error instanceof errors.AccessKeyNotFound) { + return next(new restify.NotFoundError(error.message)); + } return next(new restify.InternalServerError()); } } - public async setAccessKeyQuota(req: RequestType, res: ResponseType, next: restify.Next) { + public async setAccessKeyDataLimit(req: RequestType, res: ResponseType, next: restify.Next) { try { - logging.debug(`setAccessKeyQuota request ${JSON.stringify(req.params)}`); + logging.debug(`setAccessKeyDataLimit request ${JSON.stringify(req.params)}`); const accessKeyId = req.params.id; - const quota = req.params.quota; - // TODO(alalama): remove these checks once the repository supports typed errors. - if (!quota || !quota.data || !quota.window) { - return next(new restify.InvalidArgumentError( - 'Must provide a quota value with "data.bytes" and "window.hours"')); - } - if (quota.data.bytes < 0 || quota.window.hours < 0) { - return next(new restify.InvalidArgumentError('Must provide positive quota values')); - } - const success = await this.accessKeys.setAccessKeyQuota(accessKeyId, quota); - if (!success) { - return next(new restify.NotFoundError(`No access key found with id ${accessKeyId}`)); + const limit = req.params.limit; + if (!limit) { + return next( + new restify.MissingParameterError({statusCode: 400}, 'Missing `limit` parameter')); } + await this.accessKeys.setAccessKeyDataLimit(accessKeyId, limit); res.send(HttpSuccess.NO_CONTENT); return next(); } catch (error) { logging.error(error); + if (error instanceof errors.InvalidAccessKeyDataLimit) { + return next(new restify.InvalidArgumentError({statusCode: 400}, error.message)); + } else if (error instanceof errors.AccessKeyNotFound) { + return next(new restify.NotFoundError(error.message)); + } return next(new restify.InternalServerError()); } } - public async removeAccessKeyQuota(req: RequestType, res: ResponseType, next: restify.Next) { + public async removeAccessKeyDataLimit(req: RequestType, res: ResponseType, next: restify.Next) { try { - logging.debug(`removeAccessKeyQuota request ${JSON.stringify(req.params)}`); + logging.debug(`removeAccessKeyDataLimit request ${JSON.stringify(req.params)}`); const accessKeyId = req.params.id; - const success = await this.accessKeys.removeAccessKeyQuota(accessKeyId); - if (!success) { - return next(new restify.NotFoundError(`No access key found with id ${accessKeyId}`)); - } + await this.accessKeys.removeAccessKeyDataLimit(accessKeyId); res.send(HttpSuccess.NO_CONTENT); return next(); } catch (error) { logging.error(error); + if (error instanceof errors.AccessKeyNotFound) { + return next(new restify.NotFoundError(error.message)); + } return next(new restify.InternalServerError()); } } diff --git a/src/shadowbox/server/server_access_key.spec.ts b/src/shadowbox/server/server_access_key.spec.ts index 60e5bd09..77f7e529 100644 --- a/src/shadowbox/server/server_access_key.spec.ts +++ b/src/shadowbox/server/server_access_key.spec.ts @@ -17,7 +17,7 @@ import * as net from 'net'; import {ManualClock} from '../infrastructure/clock'; import {PortProvider} from '../infrastructure/get_port'; import {InMemoryConfig} from '../infrastructure/json_config'; -import {AccessKeyQuota, AccessKeyRepository} from '../model/access_key'; +import {AccessKeyDataLimit, AccessKeyRepository} from '../model/access_key'; import * as errors from '../model/errors'; import {FakePrometheusClient, FakeShadowsocksServer} from './mocks/mocks'; @@ -38,11 +38,11 @@ describe('ServerAccessKeyRepository', () => { }); }); - it('Creates access keys without quota and under quota', async (done) => { + it('Creates access keys without limit and under limit', async (done) => { const repo = new RepoBuilder().build(); const accessKey = await repo.createNewAccessKey(); - expect(accessKey.quotaUsage).toBeUndefined(); - expect(accessKey.isOverQuota()).toBeFalsy(); + expect(accessKey.dataLimitUsage).toBeUndefined(); + expect(accessKey.isOverDataLimit()).toBeFalsy(); done(); }); @@ -50,19 +50,17 @@ describe('ServerAccessKeyRepository', () => { const repo = new RepoBuilder().build(); repo.createNewAccessKey().then((accessKey) => { expect(countAccessKeys(repo)).toEqual(1); - const removeResult = repo.removeAccessKey(accessKey.id); - expect(removeResult).toEqual(true); + expect(repo.removeAccessKey.bind(repo, accessKey.id)).not.toThrow(); expect(countAccessKeys(repo)).toEqual(0); done(); }); }); - it('removeAccessKey returns false for missing keys', (done) => { + it('removeAccessKey throws for missing keys', (done) => { const repo = new RepoBuilder().build(); repo.createNewAccessKey().then((accessKey) => { expect(countAccessKeys(repo)).toEqual(1); - const removeResult = repo.removeAccessKey('badId'); - expect(removeResult).toEqual(false); + expect(repo.removeAccessKey.bind(repo, 'badId')).toThrowError(errors.AccessKeyNotFound); expect(countAccessKeys(repo)).toEqual(1); done(); }); @@ -72,8 +70,7 @@ describe('ServerAccessKeyRepository', () => { const repo = new RepoBuilder().build(); repo.createNewAccessKey().then((accessKey) => { const NEW_NAME = 'newName'; - const renameResult = repo.renameAccessKey(accessKey.id, NEW_NAME); - expect(renameResult).toEqual(true); + expect(repo.renameAccessKey.bind(repo, accessKey.id, NEW_NAME)).not.toThrow(); // List keys again and expect to see the NEW_NAME. const accessKeys = repo.listAccessKeys(); expect(accessKeys[0].name).toEqual(NEW_NAME); @@ -81,12 +78,12 @@ describe('ServerAccessKeyRepository', () => { }); }); - it('renameAccessKey returns false for missing keys', (done) => { + it('renameAccessKey throws for missing keys', (done) => { const repo = new RepoBuilder().build(); repo.createNewAccessKey().then((accessKey) => { const NEW_NAME = 'newName'; - const renameResult = repo.renameAccessKey('badId', NEW_NAME); - expect(renameResult).toEqual(false); + expect(repo.renameAccessKey.bind(repo, 'badId', NEW_NAME)) + .toThrowError(errors.AccessKeyNotFound); // List keys again and expect to NOT see the NEW_NAME. const accessKeys = repo.listAccessKeys(); expect(accessKeys[0].name).not.toEqual(NEW_NAME); @@ -127,20 +124,12 @@ describe('ServerAccessKeyRepository', () => { it('setPortForNewAccessKeys rejects invalid port numbers', async (done) => { const repo = new RepoBuilder().build(); - // jasmine.toThrowError expects a function and makes the code - // hard to read. - const expectThrow = async (port: number) => { - try { - await repo.setPortForNewAccessKeys(port); - fail(`setPortForNewAccessKeys should reject invalid port number ${port}.`); - } catch (error) { - expect(error instanceof errors.InvalidPortNumber).toBeTruthy(); - } - }; - await expectThrow(0); - await expectThrow(-1); - await expectThrow(100.1); - await expectThrow(65536); + await expectAsyncThrow(repo.setPortForNewAccessKeys.bind(repo, 0), errors.InvalidPortNumber); + await expectAsyncThrow(repo.setPortForNewAccessKeys.bind(repo, -1), errors.InvalidPortNumber); + await expectAsyncThrow( + repo.setPortForNewAccessKeys.bind(repo, 100.1), errors.InvalidPortNumber); + await expectAsyncThrow( + repo.setPortForNewAccessKeys.bind(repo, 65536), errors.InvalidPortNumber); done(); }); @@ -167,68 +156,64 @@ describe('ServerAccessKeyRepository', () => { const repo = new RepoBuilder().port(oldPort).build(); await repo.createNewAccessKey(); - // jasmine.toThrowError expects a function and makes the code - // hard to read. We also can't do anything like - // `expect(repo.setPortForNewAccessKeys.bind(repo, port)).not.toThrow()` - // since setPortForNewAccessKeys is async and this would lead to false positives - // when expect() returns before setPortForNewAccessKeys throws. - const expectNoThrow = async (port: number) => { - try { - await repo.setPortForNewAccessKeys(port); - } catch (error) { - fail(`setPortForNewAccessKeys should accept port ${port}.`); - } - }; - - await expectNoThrow(await portProvider.reserveNewPort()); - + await expectNoAsyncThrow(portProvider.reserveNewPort.bind(portProvider)); // simulate the first key's connection on its port const server = new net.Server(); server.listen(oldPort, async () => { - await expectNoThrow(oldPort); + await expectNoAsyncThrow(repo.setPortForNewAccessKeys.bind(repo, oldPort)); server.close(); done(); }); }); - it('Can set access key quota', async (done) => { + it('Can set access key data limit', async (done) => { const repo = new RepoBuilder().build(); const accessKey = await repo.createNewAccessKey(); - const quota = {data: {bytes: 5000}, window: {hours: 24}}; - expect(await repo.setAccessKeyQuota(accessKey.id, quota)).toBeTruthy(); - const accessKeys = repo.listAccessKeys(); - expect(accessKeys[0].quotaUsage.quota).toEqual(quota); - expect(accessKeys[0].quotaUsage.usage.bytes).toEqual(0); + const limit = {data: {bytes: 5000}, timeframe: {hours: 24}}; + await expectNoAsyncThrow(repo.setAccessKeyDataLimit.bind(repo, accessKey.id, limit)); + expect(accessKey.dataLimitUsage.limit).toEqual(limit); + expect(accessKey.dataLimitUsage.usage.bytes).toEqual(0); done(); }); - it('setAccessKeyQuota returns false for missing keys', async (done) => { + it('setAccessKeyDataLimit throws for missing keys', async (done) => { const repo = new RepoBuilder().build(); await repo.createNewAccessKey(); - const quota = {data: {bytes: 1000}, window: {hours: 24}}; - expect(await repo.setAccessKeyQuota('doesnotexist', quota)).toBeFalsy(); + const limit = {data: {bytes: 1000}, timeframe: {hours: 24}}; + await expectAsyncThrow( + repo.setAccessKeyDataLimit.bind(repo, 'doesnotexist', limit), errors.AccessKeyNotFound); done(); }); - it('setAccessKeyQuota fails with disallowed quota values', async (done) => { + it('setAccessKeyDataLimit fails with disallowed limit values', async (done) => { const repo = new RepoBuilder().build(); const accessKey = await repo.createNewAccessKey(); // Negative values - const negativeBytesQuota = {data: {bytes: -1000}, window: {hours: 24}}; - expect(await repo.setAccessKeyQuota(accessKey.id, negativeBytesQuota)).toBeFalsy(); - const negativeWindowQuota = {data: {bytes: 1000}, window: {hours: -24}}; - expect(await repo.setAccessKeyQuota(accessKey.id, negativeWindowQuota)).toBeFalsy(); + const negativeBytesLimit = {data: {bytes: -1000}, timeframe: {hours: 24}}; + await expectAsyncThrow( + repo.setAccessKeyDataLimit.bind(repo, accessKey.id, negativeBytesLimit), + errors.InvalidAccessKeyDataLimit); + const negativeTimeframeLimit = {data: {bytes: 1000}, timeframe: {hours: -24}}; + await expectAsyncThrow( + repo.setAccessKeyDataLimit.bind(repo, accessKey.id, negativeTimeframeLimit), + errors.InvalidAccessKeyDataLimit); // Missing properties - const missingDataQuota = {window: {hours: 24}} as AccessKeyQuota; - expect(await repo.setAccessKeyQuota(accessKey.id, missingDataQuota)).toBeFalsy(); - const missingWindowQuota = {data: {bytes: 1000}} as AccessKeyQuota; - expect(await repo.setAccessKeyQuota(accessKey.id, missingWindowQuota)).toBeFalsy(); - // Undefined quota - expect(await repo.setAccessKeyQuota(accessKey.id, undefined)).toBeFalsy(); + const missingDataLimit = {timeframe: {hours: 24}} as AccessKeyDataLimit; + await expectAsyncThrow( + repo.setAccessKeyDataLimit.bind(repo, accessKey.id, missingDataLimit), + errors.InvalidAccessKeyDataLimit); + const missingTimeframeLimit = {data: {bytes: 1000}} as AccessKeyDataLimit; + await expectAsyncThrow( + repo.setAccessKeyDataLimit.bind(repo, accessKey.id, missingTimeframeLimit), + errors.InvalidAccessKeyDataLimit); + // Undefined limit + await expectAsyncThrow( + repo.setAccessKeyDataLimit.bind(repo, accessKey.id, undefined), + errors.InvalidAccessKeyDataLimit); done(); }); - it('setAccessKeyQuota updates keys quota status', async (done) => { + it('setAccessKeyDataLimit updates keys limit status', async (done) => { const server = new FakeShadowsocksServer(); const prometheusClient = new FakePrometheusClient({'0': 500, '1': 200}); const repo = @@ -237,95 +222,47 @@ describe('ServerAccessKeyRepository', () => { const accessKey1 = await repo.createNewAccessKey(); const accessKey2 = await repo.createNewAccessKey(); - await repo.setAccessKeyQuota(accessKey1.id, {data: {bytes: 200}, window: {hours: 1}}); - let accessKeys = await repo.listAccessKeys(); - expect(accessKeys[0].isOverQuota()).toBeTruthy(); - expect(accessKeys[1].isOverQuota()).toBeFalsy(); + await repo.setAccessKeyDataLimit(accessKey1.id, {data: {bytes: 200}, timeframe: {hours: 1}}); + expect(accessKey1.isOverDataLimit()).toBeTruthy(); + expect(accessKey2.isOverDataLimit()).toBeFalsy(); // We determine which access keys have been enabled/disabled by accessing them from // the server's perspective, ensuring `server.update` has been called. let serverAccessKeys = server.getAccessKeys(); expect(serverAccessKeys.length).toEqual(1); expect(serverAccessKeys[0].id).toEqual(accessKey2.id); - // The over-quota access key should be re-enabled after increasing its quota, while the - // under-quota key should be disabled after setting its quota. + // The over-limit access key should be re-enabled after increasing its limit, while the + // under-limit key should be disabled after setting its limit. prometheusClient.bytesTransferredById = {'0': 800, '1': 199}; - await repo.setAccessKeyQuota(accessKey1.id, {data: {bytes: 1000}, window: {hours: 1}}); - await repo.setAccessKeyQuota(accessKey2.id, {data: {bytes: 100}, window: {hours: 1}}); - accessKeys = await repo.listAccessKeys(); - expect(accessKeys[0].isOverQuota()).toBeFalsy(); - expect(accessKeys[1].isOverQuota()).toBeTruthy(); + await repo.setAccessKeyDataLimit(accessKey1.id, {data: {bytes: 1000}, timeframe: {hours: 1}}); + await repo.setAccessKeyDataLimit(accessKey2.id, {data: {bytes: 100}, timeframe: {hours: 1}}); + expect(accessKey1.isOverDataLimit()).toBeFalsy(); + expect(accessKey2.isOverDataLimit()).toBeTruthy(); serverAccessKeys = server.getAccessKeys(); expect(serverAccessKeys.length).toEqual(1); expect(serverAccessKeys[0].id).toEqual(accessKey1.id); done(); }); - it('can remove access key quotas', async (done) => { + it('can remove access key limits', async (done) => { const repo = new RepoBuilder().build(); const accessKey = await repo.createNewAccessKey(); - await expect(repo.setAccessKeyQuota(accessKey.id, {data: {bytes: 100}, window: {hours: 24}})) - .toBeTruthy(); - expect(repo.listAccessKeys()[0].quotaUsage).toBeDefined(); - expect(repo.removeAccessKeyQuota(accessKey.id)).toBeTruthy(); - expect(repo.listAccessKeys()[0].quotaUsage).toBeUndefined(); + const limit = {data: {bytes: 100}, timeframe: {hours: 24}}; + await repo.setAccessKeyDataLimit(accessKey.id, limit); + expect(accessKey.dataLimitUsage).toBeDefined(); + await expectNoAsyncThrow(repo.removeAccessKeyDataLimit.bind(repo, accessKey.id)); + expect(accessKey.dataLimitUsage).toBeUndefined(); done(); }); - it('removeAccessKeyQuota returns false for missing keys', async (done) => { + it('removeAccessKeyDataLimit throws for missing keys', async (done) => { const repo = new RepoBuilder().build(); await repo.createNewAccessKey(); - expect(await repo.removeAccessKeyQuota('doesnotexist')).toBeFalsy(); + await expectAsyncThrow( + repo.removeAccessKeyDataLimit.bind(repo, 'doesnotexist'), errors.AccessKeyNotFound); done(); }); - it('removeAccessKeyQuota restores over-quota access keys when removing quota ', async (done) => { - const server = new FakeShadowsocksServer(); - const prometheusClient = new FakePrometheusClient({'0': 500, '1': 100}); - const repo = - new RepoBuilder().prometheusClient(prometheusClient).shadowsocksServer(server).build(); - - const accessKey = await repo.createNewAccessKey(); - await repo.createNewAccessKey(); - await repo.setAccessKeyQuota(accessKey.id, {data: {bytes: 100}, window: {hours: 1}}); - expect(server.getAccessKeys().length).toEqual(1); - - // Remove the quota; expect the key to be under quota and enabled. - expect(repo.removeAccessKeyQuota(accessKey.id)).toBeTruthy(); - expect(server.getAccessKeys().length).toEqual(2); - const accessKeys = await repo.listAccessKeys(); - expect(accessKeys[0].isOverQuota()).toBeFalsy(); - expect(accessKeys[1].isOverQuota()).toBeFalsy(); - expect(accessKeys[0].quotaUsage).toBeUndefined(); - expect(accessKeys[1].quotaUsage).toBeUndefined(); - done(); - }); - - it('enforceAccessKeyQuotas updates keys quota status ', async (done) => { - const prometheusClient = new FakePrometheusClient({'0': 500, '1': 100}); - const repo = new RepoBuilder().prometheusClient(prometheusClient).build(); - - const accessKey1 = await repo.createNewAccessKey(); - await repo.createNewAccessKey(); - await repo.setAccessKeyQuota(accessKey1.id, {data: {bytes: 200}, window: {hours: 1}}); - - await repo.enforceAccessKeyQuotas(); - let accessKeys = await repo.listAccessKeys(); - expect(accessKeys[0].isOverQuota()).toBeTruthy(); - expect(accessKeys[1].isOverQuota()).toBeFalsy(); - expect(accessKeys[0].quotaUsage.usage.bytes).toEqual(500); - expect(accessKeys[1].quotaUsage).toBeUndefined(); - - prometheusClient.bytesTransferredById = {'0': 100, '1': 100}; - await repo.enforceAccessKeyQuotas(); - accessKeys = await repo.listAccessKeys(); - expect(accessKeys[0].isOverQuota()).toBeFalsy(); - expect(accessKeys[1].isOverQuota()).toBeFalsy(); - expect(accessKeys[0].quotaUsage.usage.bytes).toEqual(100); - expect(accessKeys[1].quotaUsage).toBeUndefined(); - done(); - }); - - it('enforceAccessKeyQuotas enables and disables keys', async (done) => { + it('removeAccessKeyDataLimit restores over-limit access keys', async (done) => { const server = new FakeShadowsocksServer(); const prometheusClient = new FakePrometheusClient({'0': 500, '1': 100}); const repo = @@ -333,16 +270,60 @@ describe('ServerAccessKeyRepository', () => { const accessKey1 = await repo.createNewAccessKey(); const accessKey2 = await repo.createNewAccessKey(); - await repo.setAccessKeyQuota(accessKey1.id, {data: {bytes: 200}, window: {hours: 1}}); + await repo.setAccessKeyDataLimit(accessKey1.id, {data: {bytes: 100}, timeframe: {hours: 1}}); + expect(server.getAccessKeys().length).toEqual(1); - await repo.enforceAccessKeyQuotas(); + // Remove the limit; expect the key to be under limit and enabled. + await expectNoAsyncThrow(repo.removeAccessKeyDataLimit.bind(repo, accessKey1.id)); + expect(server.getAccessKeys().length).toEqual(2); + expect(accessKey1.isOverDataLimit()).toBeFalsy(); + expect(accessKey2.isOverDataLimit()).toBeFalsy(); + expect(accessKey1.dataLimitUsage).toBeUndefined(); + expect(accessKey2.dataLimitUsage).toBeUndefined(); + done(); + }); + + it('enforceAccessKeyDataLimits updates keys limit status', async (done) => { + const prometheusClient = new FakePrometheusClient({'0': 500, '1': 100}); + const repo = new RepoBuilder().prometheusClient(prometheusClient).build(); + + const accessKey1 = await repo.createNewAccessKey(); + const accessKey2 = await repo.createNewAccessKey(); + await repo.setAccessKeyDataLimit(accessKey1.id, {data: {bytes: 200}, timeframe: {hours: 1}}); + + await repo.enforceAccessKeyDataLimits(); + expect(accessKey1.isOverDataLimit()).toBeTruthy(); + expect(accessKey2.isOverDataLimit()).toBeFalsy(); + expect(accessKey1.dataLimitUsage.usage.bytes).toEqual(500); + expect(accessKey2.dataLimitUsage).toBeUndefined(); + + prometheusClient.bytesTransferredById = {'0': 100, '1': 100}; + await repo.enforceAccessKeyDataLimits(); + expect(accessKey1.isOverDataLimit()).toBeFalsy(); + expect(accessKey2.isOverDataLimit()).toBeFalsy(); + expect(accessKey1.dataLimitUsage.usage.bytes).toEqual(100); + expect(accessKey2.dataLimitUsage).toBeUndefined(); + done(); + }); + + it('enforceAccessKeyDataLimits enables and disables keys', async (done) => { + const server = new FakeShadowsocksServer(); + const prometheusClient = new FakePrometheusClient({'0': 500, '1': 100}); + const repo = + new RepoBuilder().prometheusClient(prometheusClient).shadowsocksServer(server).build(); + + const accessKey1 = await repo.createNewAccessKey(); + const accessKey2 = await repo.createNewAccessKey(); + await repo.setAccessKeyDataLimit(accessKey1.id, {data: {bytes: 200}, timeframe: {hours: 1}}); + + await repo.enforceAccessKeyDataLimits(); const accessKeys = await repo.listAccessKeys(); let serverAccessKeys = server.getAccessKeys(); expect(serverAccessKeys.length).toEqual(1); expect(serverAccessKeys[0].id).toEqual(accessKey2.id); prometheusClient.bytesTransferredById = {'0': 100, '1': 100}; - await repo.enforceAccessKeyQuotas(); + await repo.enforceAccessKeyDataLimits(); serverAccessKeys = server.getAccessKeys(); expect(serverAccessKeys.length).toEqual(2); done(); @@ -354,7 +335,7 @@ describe('ServerAccessKeyRepository', () => { // Create 2 new access keys await Promise.all([repo1.createNewAccessKey(), repo1.createNewAccessKey()]); // Modify properties - await repo1.setAccessKeyQuota('0', {data: {bytes: 100}, window: {hours: 12}}); + await repo1.setAccessKeyDataLimit('0', {data: {bytes: 100}, timeframe: {hours: 12}}); repo1.renameAccessKey('1', 'name'); // Create a 2nd repo from the same config file. This simulates what @@ -401,42 +382,38 @@ describe('ServerAccessKeyRepository', () => { done(); }); - it('start periodically enforces access key quotas', async (done) => { + it('start periodically enforces access key data limits', async (done) => { const server = new FakeShadowsocksServer(); - const prometheusClient = new FakePrometheusClient({'0': 500, '1': 300, '2': 1000}); + const prometheusClient = new FakePrometheusClient({'0': 500, '1': 300, '2': 400}); const repo = new RepoBuilder().prometheusClient(prometheusClient).shadowsocksServer(server).build(); - const accessKey1 = await repo.createNewAccessKey(); const accessKey2 = await repo.createNewAccessKey(); const accessKey3 = await repo.createNewAccessKey(); - await repo.setAccessKeyQuota(accessKey1.id, {data: {bytes: 300}, window: {hours: 1}}); - await repo.setAccessKeyQuota(accessKey2.id, {data: {bytes: 100}, window: {hours: 1}}); + await repo.setAccessKeyDataLimit(accessKey1.id, {data: {bytes: 300}, timeframe: {hours: 1}}); + await repo.setAccessKeyDataLimit(accessKey2.id, {data: {bytes: 100}, timeframe: {hours: 1}}); const clock = new ManualClock(); await repo.start(clock); await clock.runCallbacks(); - let accessKeys = await repo.listAccessKeys(); - expect(accessKeys[0].isOverQuota()).toBeTruthy(); - expect(accessKeys[1].isOverQuota()).toBeTruthy(); - expect(accessKeys[2].isOverQuota()).toBeFalsy(); - expect(accessKeys[0].quotaUsage.usage.bytes).toEqual(500); - expect(accessKeys[1].quotaUsage.usage.bytes).toEqual(300); - expect(accessKeys[2].quotaUsage).toBeUndefined(); + expect(accessKey1.isOverDataLimit()).toBeTruthy(); + expect(accessKey2.isOverDataLimit()).toBeTruthy(); + expect(accessKey3.isOverDataLimit()).toBeFalsy(); + expect(accessKey1.dataLimitUsage.usage.bytes).toEqual(500); + expect(accessKey2.dataLimitUsage.usage.bytes).toEqual(300); + expect(accessKey3.dataLimitUsage).toBeUndefined(); let serverAccessKeys = await server.getAccessKeys(); expect(serverAccessKeys.length).toEqual(1); expect(serverAccessKeys[0].id).toEqual(accessKey3.id); - // Simulate a change in usage. prometheusClient.bytesTransferredById = {'0': 100, '1': 300, '2': 1000}; await clock.runCallbacks(); - accessKeys = await repo.listAccessKeys(); - expect(accessKeys[0].isOverQuota()).toBeFalsy(); - expect(accessKeys[1].isOverQuota()).toBeTruthy(); - expect(accessKeys[2].isOverQuota()).toBeFalsy(); - expect(accessKeys[0].quotaUsage.usage.bytes).toEqual(100); - expect(accessKeys[1].quotaUsage.usage.bytes).toEqual(300); - expect(accessKeys[2].quotaUsage).toBeUndefined(); + expect(accessKey1.isOverDataLimit()).toBeFalsy(); + expect(accessKey2.isOverDataLimit()).toBeTruthy(); + expect(accessKey3.isOverDataLimit()).toBeFalsy(); + expect(accessKey1.dataLimitUsage.usage.bytes).toEqual(100); + expect(accessKey2.dataLimitUsage.usage.bytes).toEqual(300); + expect(accessKey3.dataLimitUsage).toBeUndefined(); serverAccessKeys = await server.getAccessKeys(); expect(serverAccessKeys.length).toEqual(2); expect(serverAccessKeys[0].id).toEqual(accessKey1.id); @@ -461,6 +438,30 @@ describe('ServerAccessKeyRepository', () => { }); }); +// Convenience function to expect that an asynchronous function does not throw an error. Note that +// jasmine.toThrowError lacks asynchronous support and could lead to false positives. +async function expectNoAsyncThrow(fn: Function) { + try { + await fn(); + } catch (e) { + fail(`Unexpected error thrown: ${e}`); + } +} + +// Convenience function to expect that an asynchronous function throws an error. Fails if the thrown +// error does not match `errorType`, when defined. +// tslint:disable-next-line:no-any +async function expectAsyncThrow(fn: Function, errorType?: new (...args: any[]) => Error) { + try { + await fn(); + fail(`Expected error to be thrown`); + } catch (e) { + if (!!errorType && !(e instanceof errorType)) { + fail(`Thrown error is not of type ${errorType.name}. Got ${e.name}`); + } + } +} + function countAccessKeys(repo: AccessKeyRepository) { return repo.listAccessKeys().length; } diff --git a/src/shadowbox/server/server_access_key.ts b/src/shadowbox/server/server_access_key.ts index 38d8d0c9..b85c291b 100644 --- a/src/shadowbox/server/server_access_key.ts +++ b/src/shadowbox/server/server_access_key.ts @@ -20,7 +20,7 @@ import {isPortUsed} from '../infrastructure/get_port'; import {JsonConfig} from '../infrastructure/json_config'; import * as logging from '../infrastructure/logging'; import {PrometheusClient} from '../infrastructure/prometheus_scraper'; -import {AccessKey, AccessKeyId, AccessKeyMetricsId, AccessKeyQuota, AccessKeyQuotaUsage, AccessKeyRepository, ProxyParams} from '../model/access_key'; +import {AccessKey, AccessKeyDataLimit, AccessKeyDataLimitUsage, AccessKeyId, AccessKeyMetricsId, AccessKeyRepository, ProxyParams} from '../model/access_key'; import * as errors from '../model/errors'; import {ShadowsocksServer} from '../model/shadowsocks_server'; @@ -32,7 +32,7 @@ interface AccessKeyJson { password: string; port: number; encryptionMethod?: string; - quota?: AccessKeyQuota; + limit?: AccessKeyDataLimit; } // The configuration file format as json. @@ -46,16 +46,22 @@ export interface AccessKeyConfigJson { class ServerAccessKey implements AccessKey { constructor( readonly id: AccessKeyId, public name: string, public metricsId: AccessKeyMetricsId, - readonly proxyParams: ProxyParams, public quotaUsage?: AccessKeyQuotaUsage) {} + readonly proxyParams: ProxyParams, public dataLimitUsage?: AccessKeyDataLimitUsage) {} - isOverQuota(): boolean { - if (!this.quotaUsage) { + isOverDataLimit(): boolean { + if (!this.dataLimitUsage) { return false; } - return this.quotaUsage.usage.bytes > this.quotaUsage.quota.data.bytes; + return this.dataLimitUsage.usage.bytes > this.dataLimitUsage.limit.data.bytes; } } +function isValidAccessKeyDataLimit(limit: AccessKeyDataLimit) { + return limit && limit.data && limit.timeframe && Number.isInteger(limit.data.bytes) && + limit.data.bytes >= 0 && Number.isInteger(limit.timeframe.hours) && + limit.timeframe.hours >= 0; +} + // Generates a random password for Shadowsocks access keys. function generatePassword(): string { return randomstring.generate(12); @@ -68,10 +74,10 @@ function makeAccessKey(hostname: string, accessKeyJson: AccessKeyJson): AccessKe encryptionMethod: accessKeyJson.encryptionMethod, password: accessKeyJson.password, }; - const quotaUsage = - accessKeyJson.quota ? {quota: accessKeyJson.quota, usage: {bytes: 0}} : undefined; + const dataLimitUsage = + accessKeyJson.limit ? {limit: accessKeyJson.limit, usage: {bytes: 0}} : undefined; return new ServerAccessKey( - accessKeyJson.id, accessKeyJson.name, accessKeyJson.metricsId, proxyParams, quotaUsage); + accessKeyJson.id, accessKeyJson.name, accessKeyJson.metricsId, proxyParams, dataLimitUsage); } function makeAccessKeyJson(accessKey: AccessKey): AccessKeyJson { @@ -82,7 +88,7 @@ function makeAccessKeyJson(accessKey: AccessKey): AccessKeyJson { password: accessKey.proxyParams.password, port: accessKey.proxyParams.portNumber, encryptionMethod: accessKey.proxyParams.encryptionMethod, - quota: accessKey.quotaUsage ? accessKey.quotaUsage.quota : undefined + limit: accessKey.dataLimitUsage ? accessKey.dataLimitUsage.limit : undefined }; } @@ -90,7 +96,7 @@ function makeAccessKeyJson(accessKey: AccessKey): AccessKeyJson { // to start and stop per-access-key Shadowsocks instances. Requires external validation // that portForNewAccessKeys is valid. export class ServerAccessKeyRepository implements AccessKeyRepository { - private static QUOTA_ENFORCEMENT_INTERVAL_MS = 60 * 60 * 1000; // 1h + private static LIMIT_ENFORCEMENT_INTERVAL_MS = 60 * 60 * 1000; // 1h private NEW_USER_ENCRYPTION_METHOD = 'chacha20-ietf-poly1305'; private accessKeys: ServerAccessKey[]; @@ -108,17 +114,17 @@ export class ServerAccessKeyRepository implements AccessKeyRepository { } // Starts the Shadowsocks server and exposes the access key configuration to the server. - // Periodically enforces access key quotas. + // Periodically enforces access key limits. async start(clock: Clock): Promise { - await this.enforceAccessKeyQuotas(); + await this.enforceAccessKeyDataLimits(); await this.updateServer(); clock.setInterval(async () => { try { - await this.enforceAccessKeyQuotas(); + await this.enforceAccessKeyDataLimits(); } catch (e) { - logging.error(`Failed to enforce access key quotas: ${e}`); + logging.error(`Failed to enforce access key limits: ${e}`); } - }, ServerAccessKeyRepository.QUOTA_ENFORCEMENT_INTERVAL_MS); + }, ServerAccessKeyRepository.LIMIT_ENFORCEMENT_INTERVAL_MS); } private isExistingAccessKeyPort(port: number): boolean { @@ -155,75 +161,51 @@ export class ServerAccessKeyRepository implements AccessKeyRepository { return accessKey; } - removeAccessKey(id: AccessKeyId): boolean { + removeAccessKey(id: AccessKeyId) { for (let ai = 0; ai < this.accessKeys.length; ai++) { const accessKey = this.accessKeys[ai]; if (accessKey.id === id) { this.accessKeys.splice(ai, 1); this.saveAccessKeys(); this.updateServer(); - return true; + return; } } - return false; + throw new errors.AccessKeyNotFound(id); } listAccessKeys(): AccessKey[] { return [...this.accessKeys]; // Return a copy of the access key array. } - renameAccessKey(id: AccessKeyId, name: string): boolean { + renameAccessKey(id: AccessKeyId, name: string) { const accessKey = this.getAccessKey(id); - if (!accessKey) { - return false; - } accessKey.name = name; - try { - this.saveAccessKeys(); - } catch (error) { - return false; - } - return true; + this.saveAccessKeys(); } - async setAccessKeyQuota(id: AccessKeyId, quota: AccessKeyQuota): Promise { - if (!quota || !quota.data || !quota.window || quota.data.bytes < 0 || quota.window.hours < 0) { - return false; + async setAccessKeyDataLimit(id: AccessKeyId, limit: AccessKeyDataLimit) { + if (!isValidAccessKeyDataLimit(limit)) { + throw new errors.InvalidAccessKeyDataLimit(); } const accessKey = this.getAccessKey(id); - if (!accessKey) { - return false; + accessKey.dataLimitUsage = {limit, usage: {bytes: 0}}; + this.saveAccessKeys(); + const limitStautsChanged = await this.updateAccessKeyDataLimitStatus(accessKey); + if (limitStautsChanged) { + // Reflect the access key limit status if it changed with the new limit. + await this.updateServer(); } - accessKey.quotaUsage = {quota, usage: {bytes: 0}}; - try { - this.saveAccessKeys(); - const quotaStautsChanged = await this.updateAccessKeyQuotaStatus(accessKey); - if (quotaStautsChanged) { - // Reflect the access key quota status if it changed with the new quota. - await this.updateServer(); - } - } catch (error) { - return false; - } - return true; } - async removeAccessKeyQuota(id: AccessKeyId): Promise { + async removeAccessKeyDataLimit(id: AccessKeyId) { const accessKey = this.getAccessKey(id); - if (!accessKey) { - return false; + const wasOverDataLimit = accessKey.isOverDataLimit(); + accessKey.dataLimitUsage = undefined; + this.saveAccessKeys(); + if (wasOverDataLimit) { + await this.updateServer(); } - const wasOverQuota = accessKey.isOverQuota(); - accessKey.quotaUsage = undefined; - try { - this.saveAccessKeys(); - if (wasOverQuota) { - await this.updateServer(); - } - } catch (error) { - return false; - } - return true; } getMetricsId(id: AccessKeyId): AccessKeyMetricsId|undefined { @@ -231,44 +213,45 @@ export class ServerAccessKeyRepository implements AccessKeyRepository { return accessKey ? accessKey.metricsId : undefined; } - // Compares access key usage with collected metrics, marking them as under or over quota. - async enforceAccessKeyQuotas() { - let quotaStatusChanged = false; + // Compares access key usage with collected metrics, marking them as under or over limit. + async enforceAccessKeyDataLimits() { + let limitStatusChanged = false; for (const accessKey of this.accessKeys) { - quotaStatusChanged = quotaStatusChanged || await this.updateAccessKeyQuotaStatus(accessKey); + limitStatusChanged = + await this.updateAccessKeyDataLimitStatus(accessKey) || limitStatusChanged; } - if (quotaStatusChanged) { - this.updateServer(); + if (limitStatusChanged) { + await this.updateServer(); } } - // Updates `accessKey` quota status by comparing its usage with collected metrics. Returns whether - // the quota status changed. - private async updateAccessKeyQuotaStatus(accessKey: ServerAccessKey): Promise { - if (!accessKey.quotaUsage) { - return false; // Don't query the usage of access keys without quota. + // Updates `accessKey` data limit status by comparing its usage with collected metrics. + // Returns whether the data limit status changed. + private async updateAccessKeyDataLimitStatus(accessKey: ServerAccessKey): Promise { + if (!accessKey.dataLimitUsage) { + return false; // Don't query the usage of access keys without limit. } - const wasOverQuota = accessKey.isOverQuota(); - const bytesTransferred = - await this.getOutboundByteTransfer(accessKey.id, accessKey.quotaUsage.quota.window.hours); - accessKey.quotaUsage.usage.bytes = bytesTransferred; - const isOverQuota = accessKey.isOverQuota(); - const quotaStatusChanged = isOverQuota !== wasOverQuota; - if (quotaStatusChanged) { - logging.debug(`Access key "${accessKey.id}" quota status changed. Quota: ${ - JSON.stringify(accessKey.quotaUsage)}, isOverQuota: ${isOverQuota}`); + const wasOverDataLimit = accessKey.isOverDataLimit(); + const bytesTransferred = await this.getOutboundByteTransfer( + accessKey.id, accessKey.dataLimitUsage.limit.timeframe.hours); + accessKey.dataLimitUsage.usage.bytes = bytesTransferred; + const isOverDataLimit = accessKey.isOverDataLimit(); + const dataLimitStatusChanged = isOverDataLimit !== wasOverDataLimit; + if (dataLimitStatusChanged) { + logging.debug(`Access key "${accessKey.id}" limit status changed. Limit: ${ + JSON.stringify(accessKey.dataLimitUsage)}, isOverDataLimit: ${isOverDataLimit}`); } - return quotaStatusChanged; + return dataLimitStatusChanged; } - // Retrieves access key outbound data transfer in bytes for `accessKeyId` over `windowHours` + // Retrieves access key outbound data transfer in bytes for `accessKeyId` over `timeframeHours` // from a Prometheus instance. - async getOutboundByteTransfer(accessKeyId: string, windowHours: number): Promise { + async getOutboundByteTransfer(accessKeyId: string, timeframeHours: number): Promise { const escapedAccessKeyId = JSON.stringify(accessKeyId); let bytesTransferred = 0; const result = await this.prometheusClient.query( `sum(increase(shadowsocks_data_bytes{dir=~"ct",access_key=${escapedAccessKeyId}}[${ - windowHours}h])) by (access_key)`); + timeframeHours}h])) by (access_key)`); if (result && result.result[0] && result.result[0].metric['access_key'] === accessKeyId && result.result[0].value && result.result[0].value.length > 1) { bytesTransferred = Math.round(parseFloat(result.result[0].value[1])) || 0; @@ -277,7 +260,7 @@ export class ServerAccessKeyRepository implements AccessKeyRepository { } private updateServer(): Promise { - const serverAccessKeys = this.accessKeys.filter(key => !key.isOverQuota()).map(key => { + const serverAccessKeys = this.accessKeys.filter(key => !key.isOverDataLimit()).map(key => { return { id: key.id, port: key.proxyParams.portNumber, @@ -293,21 +276,17 @@ export class ServerAccessKeyRepository implements AccessKeyRepository { } private saveAccessKeys() { - try { - this.keyConfig.data().accessKeys = this.accessKeys.map(key => makeAccessKeyJson(key)); - this.keyConfig.write(); - } catch (error) { - throw new Error(`Failed to save access key config: ${error}`); - } + this.keyConfig.data().accessKeys = this.accessKeys.map(key => makeAccessKeyJson(key)); + this.keyConfig.write(); } - // Returns a reference to the access key with `id`, or undefined if the key is not found. - private getAccessKey(id: AccessKeyId): ServerAccessKey|undefined { + // Returns a reference to the access key with `id`, or throws if the key is not found. + private getAccessKey(id: AccessKeyId): ServerAccessKey { for (const accessKey of this.accessKeys) { if (accessKey.id === id) { return accessKey; } } - return undefined; + throw new errors.AccessKeyNotFound(id); } }