Access key limits (#475)

This commit is contained in:
alalamav 2019-08-28 11:11:50 -04:00 committed by GitHub
parent e956c94df4
commit cb624cd392
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 405 additions and 401 deletions

View file

@ -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

View file

@ -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<AccessKey>;
// 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<void>;
// 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<boolean>;
// Clears the transfer quota for the specified access key. Returns true if successful.
removeAccessKeyQuota(id: AccessKeyId): Promise<boolean>;
// Sets the transfer limit for the specified access key. Throws on failure.
setAccessKeyDataLimit(id: AccessKeyId, limit: AccessKeyDataLimit): Promise<void>;
// Clears the transfer limit for the specified access key. Throws on failure.
removeAccessKeyDataLimit(id: AccessKeyId): Promise<void>;
}

View file

@ -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"');
}
}

View file

@ -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"

View file

@ -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<AccessKeyConfigJson>({accessKeys: [], nextId: 0}),
OLD_PORT, 'hostname', new InMemoryConfig<AccessKeyConfigJson>({accessKeys: [], nextId: 0}),
new FakeShadowsocksServer(), new FakePrometheusClient({}));
}

View file

@ -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<void> {
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());
}
}

View file

@ -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;
}

View file

@ -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<void> {
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<boolean> {
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<boolean> {
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<boolean> {
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<boolean> {
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<number> {
async getOutboundByteTransfer(accessKeyId: string, timeframeHours: number): Promise<number> {
const escapedAccessKeyId = JSON.stringify(accessKeyId);
let bytesTransferred = 0;
const result = await this.prometheusClient.query(
`sum(increase(shadowsocks_data_bytes{dir=~"c<p|p>t",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<void> {
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);
}
}