mirror of
https://github.com/OutlineFoundation/outline-server.git
synced 2026-08-04 14:37:34 +00:00
Access key limits (#475)
This commit is contained in:
parent
e956c94df4
commit
cb624cd392
8 changed files with 405 additions and 401 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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({}));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue