Skip to content

Commit 57a30a2

Browse files
authored
feat(server): add optional name/limit/password props for createNewAccessKey method (#1273)
1 parent 4537fdd commit 57a30a2

6 files changed

+197
-26
lines changed

src/shadowbox/model/access_key.ts

+12-1
Original file line numberDiff line numberDiff line change
@@ -48,9 +48,20 @@ export interface AccessKey {
4848
readonly dataLimit?: DataLimit;
4949
}
5050

51+
export interface AccessKeyCreateParams {
52+
// The encryption method to use for the access key.
53+
readonly encryptionMethod?: string;
54+
// The name to give the access key.
55+
readonly name?: string;
56+
// The password to use for the access key.
57+
readonly password?: string;
58+
// The data transfer limit to apply to the access key.
59+
readonly dataLimit?: DataLimit;
60+
}
61+
5162
export interface AccessKeyRepository {
5263
// Creates a new access key. Parameters are chosen automatically.
53-
createNewAccessKey(encryptionMethod?: string): Promise<AccessKey>;
64+
createNewAccessKey(params?: AccessKeyCreateParams): Promise<AccessKey>;
5465
// Removes the access key given its id. Throws on failure.
5566
removeAccessKey(id: AccessKeyId);
5667
// Returns the access key with the given id. Throws on failure.

src/shadowbox/server/api.yml

+9-1
Original file line numberDiff line numberDiff line change
@@ -146,11 +146,19 @@ paths:
146146
schema:
147147
type: object
148148
properties:
149+
name:
150+
type: string
149151
method:
150152
type: string
153+
password:
154+
type: string
155+
port:
156+
type: integer
157+
dataLimit:
158+
$ref: "#/components/schemas/DataLimit"
151159
examples:
152160
'0':
153-
value: '{"method":"aes-192-gcm"}'
161+
value: '{"method":"aes-192-gcm","name":"First","password":"8iu8V8EeoFVpwQvQeS9wiD","limit":{"bytes":10000}}'
154162
responses:
155163
'201':
156164
description: The newly created access key

src/shadowbox/server/manager_service.spec.ts

+119
Original file line numberDiff line numberDiff line change
@@ -337,6 +337,84 @@ describe('ShadowsocksManagerService', () => {
337337
};
338338
service.createNewAccessKey({params: {method: 'aes-256-gcm'}}, res, done);
339339
});
340+
it('use default name is params is not defined', (done) => {
341+
const repo = getAccessKeyRepository();
342+
const service = new ShadowsocksManagerServiceBuilder().accessKeys(repo).build();
343+
344+
const res = {
345+
send: (httpCode, data) => {
346+
expect(httpCode).toEqual(201);
347+
expect(data.name).toEqual('');
348+
responseProcessed = true; // required for afterEach to pass.
349+
},
350+
};
351+
service.createNewAccessKey({params: {}}, res, done);
352+
});
353+
it('rejects non-string name', (done) => {
354+
const repo = getAccessKeyRepository();
355+
const service = new ShadowsocksManagerServiceBuilder().accessKeys(repo).build();
356+
357+
const res = {send: (_httpCode, _data) => {}};
358+
service.createNewAccessKey({params: {name: Number('9876')}}, res, (error) => {
359+
expect(error.statusCode).toEqual(400);
360+
responseProcessed = true; // required for afterEach to pass.
361+
done();
362+
});
363+
});
364+
it('defined name is equal to stored', (done) => {
365+
const repo = getAccessKeyRepository();
366+
const service = new ShadowsocksManagerServiceBuilder().accessKeys(repo).build();
367+
368+
const ACCESSKEY_NAME = 'accesskeyname';
369+
const res = {
370+
send: (httpCode, data) => {
371+
expect(httpCode).toEqual(201);
372+
expect(data.name).toEqual(ACCESSKEY_NAME);
373+
responseProcessed = true; // required for afterEach to pass.
374+
},
375+
};
376+
service.createNewAccessKey({params: {name: ACCESSKEY_NAME}}, res, done);
377+
});
378+
it('limit can be undefined', (done) => {
379+
const repo = getAccessKeyRepository();
380+
const service = new ShadowsocksManagerServiceBuilder().accessKeys(repo).build();
381+
382+
const res = {
383+
send: (httpCode, data) => {
384+
expect(httpCode).toEqual(201);
385+
expect(data.limit).toBeUndefined();
386+
responseProcessed = true; // required for afterEach to pass.
387+
},
388+
};
389+
service.createNewAccessKey({params: {}}, res, done);
390+
});
391+
it('rejects non-numeric limits', (done) => {
392+
const repo = getAccessKeyRepository();
393+
const service = new ShadowsocksManagerServiceBuilder().accessKeys(repo).build();
394+
395+
const ACCESSKEY_LIMIT = {bytes: '9876'};
396+
397+
const res = {send: (_httpCode, _data) => {}};
398+
service.createNewAccessKey({params: {limit: ACCESSKEY_LIMIT}}, res, (error) => {
399+
expect(error.statusCode).toEqual(400);
400+
responseProcessed = true; // required for afterEach to pass.
401+
done();
402+
});
403+
});
404+
it('defined limit is equal to stored', (done) => {
405+
const repo = getAccessKeyRepository();
406+
const service = new ShadowsocksManagerServiceBuilder().accessKeys(repo).build();
407+
408+
const ACCESSKEY_LIMIT = {bytes: 9876};
409+
const res = {
410+
send: (httpCode, data) => {
411+
expect(httpCode).toEqual(201);
412+
expect(data.dataLimit).toEqual(ACCESSKEY_LIMIT);
413+
responseProcessed = true; // required for afterEach to pass.
414+
},
415+
};
416+
service.createNewAccessKey({params: {limit: ACCESSKEY_LIMIT}}, res, done);
417+
});
340418
it('method must be of type string', (done) => {
341419
const repo = getAccessKeyRepository();
342420
const service = new ShadowsocksManagerServiceBuilder().accessKeys(repo).build();
@@ -371,6 +449,47 @@ describe('ShadowsocksManagerService', () => {
371449
done();
372450
});
373451
});
452+
453+
it('generates a new password when no password is provided', async (done) => {
454+
const repo = getAccessKeyRepository();
455+
const service = new ShadowsocksManagerServiceBuilder().accessKeys(repo).build();
456+
457+
const res = {
458+
send: (httpCode, data) => {
459+
expect(httpCode).toEqual(201);
460+
expect(data.password).toBeDefined();
461+
responseProcessed = true; // required for afterEach to pass.
462+
},
463+
};
464+
await service.createNewAccessKey({params: {}}, res, done);
465+
});
466+
467+
it('uses the provided password when one is provided', async (done) => {
468+
const repo = getAccessKeyRepository();
469+
const service = new ShadowsocksManagerServiceBuilder().accessKeys(repo).build();
470+
471+
const PASSWORD = '8iu8V8EeoFVpwQvQeS9wiD';
472+
const res = {
473+
send: (httpCode, data) => {
474+
expect(httpCode).toEqual(201);
475+
expect(data.password).toEqual(PASSWORD);
476+
responseProcessed = true; // required for afterEach to pass.
477+
},
478+
};
479+
await service.createNewAccessKey({params: {password: PASSWORD}}, res, done);
480+
});
481+
482+
it('rejects a password that is not a string', async (done) => {
483+
const repo = getAccessKeyRepository();
484+
const service = new ShadowsocksManagerServiceBuilder().accessKeys(repo).build();
485+
const PASSWORD = Number.MAX_SAFE_INTEGER;
486+
const res = {send: SEND_NOTHING};
487+
await service.createNewAccessKey({params: {password: PASSWORD}}, res, (error) => {
488+
expect(error.statusCode).toEqual(400);
489+
responseProcessed = true; // required for afterEach to pass.
490+
done();
491+
});
492+
});
374493
});
375494
describe('setPortForNewAccessKeys', () => {
376495
it('changes ports for new access keys', async (done) => {

src/shadowbox/server/manager_service.ts

+42-15
Original file line numberDiff line numberDiff line change
@@ -179,9 +179,10 @@ function validateAccessKeyId(accessKeyId: unknown): string {
179179
}
180180

181181
function validateDataLimit(limit: unknown): DataLimit {
182-
if (!limit) {
183-
throw new restifyErrors.MissingParameterError({statusCode: 400}, 'Missing `limit` parameter');
182+
if (typeof limit === 'undefined') {
183+
return undefined;
184184
}
185+
185186
const bytes = (limit as DataLimit).bytes;
186187
if (!(Number.isInteger(bytes) && bytes >= 0)) {
187188
throw new restifyErrors.InvalidArgumentError(
@@ -192,6 +193,20 @@ function validateDataLimit(limit: unknown): DataLimit {
192193
return limit as DataLimit;
193194
}
194195

196+
function validateStringParam(param: unknown, paramName: string): string {
197+
if (typeof param === 'undefined') {
198+
return undefined;
199+
}
200+
201+
if (typeof param !== 'string') {
202+
throw new restifyErrors.InvalidArgumentError(
203+
{statusCode: 400},
204+
`Expected a string for ${paramName}, instead got ${param} of type ${typeof param}`
205+
);
206+
}
207+
return param;
208+
}
209+
195210
// The ShadowsocksManagerService manages the access keys that can use the server
196211
// as a proxy using Shadowsocks. It runs an instance of the Shadowsocks server
197212
// for each existing access key, with the port and password assigned for that access key.
@@ -311,28 +326,40 @@ export class ShadowsocksManagerService {
311326
}
312327

313328
// Creates a new access key
314-
public async createNewAccessKey(req: RequestType, res: ResponseType, next: restify.Next): Promise<void> {
329+
public async createNewAccessKey(
330+
req: RequestType,
331+
res: ResponseType,
332+
next: restify.Next
333+
): Promise<void> {
315334
try {
316335
logging.debug(`createNewAccessKey request ${JSON.stringify(req.params)}`);
317-
let encryptionMethod = req.params.method;
318-
if (!encryptionMethod) {
319-
encryptionMethod = '';
320-
}
321-
if (typeof encryptionMethod !== 'string') {
322-
return next(new restifyErrors.InvalidArgumentError(
323-
{statusCode: 400},
324-
`Expected a string encryptionMethod, instead got ${encryptionMethod} of type ${
325-
typeof encryptionMethod}`));
326-
}
327-
const accessKeyJson = accessKeyToApiJson(await this.accessKeys.createNewAccessKey(encryptionMethod));
336+
const encryptionMethod = validateStringParam(req.params.method || '', 'encryptionMethod');
337+
const name = validateStringParam(req.params.name || '', 'name');
338+
const dataLimit = validateDataLimit(req.params.limit);
339+
const password = validateStringParam(req.params.password, 'password');
340+
341+
const accessKeyJson = accessKeyToApiJson(
342+
await this.accessKeys.createNewAccessKey({
343+
encryptionMethod,
344+
name,
345+
dataLimit,
346+
password,
347+
})
348+
);
328349
res.send(201, accessKeyJson);
329350
logging.debug(`createNewAccessKey response ${JSON.stringify(accessKeyJson)}`);
330351
return next();
331-
} catch(error) {
352+
} catch (error) {
332353
logging.error(error);
333354
if (error instanceof errors.InvalidCipher) {
334355
return next(new restifyErrors.InvalidArgumentError({statusCode: 400}, error.message));
335356
}
357+
if (
358+
error instanceof restifyErrors.InvalidArgumentError ||
359+
error instanceof restifyErrors.MissingParameterError
360+
) {
361+
return next(error);
362+
}
336363
return next(new restifyErrors.InternalServerError());
337364
}
338365
}

src/shadowbox/server/server_access_key.spec.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ describe('ServerAccessKeyRepository', () => {
6060

6161
it('New access keys sees the encryption method correctly', (done) => {
6262
const repo = new RepoBuilder().build();
63-
repo.createNewAccessKey('aes-256-gcm').then((accessKey) => {
63+
repo.createNewAccessKey({encryptionMethod: 'aes-256-gcm'}).then((accessKey) => {
6464
expect(accessKey).toBeDefined();
6565
expect(accessKey.proxyParams.encryptionMethod).toEqual('aes-256-gcm');
6666
done();

src/shadowbox/server/server_access_key.ts

+14-8
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import * as logging from '../infrastructure/logging';
2222
import {PrometheusClient} from '../infrastructure/prometheus_scraper';
2323
import {
2424
AccessKey,
25+
AccessKeyCreateParams,
2526
AccessKeyId,
2627
AccessKeyMetricsId,
2728
AccessKeyRepository,
@@ -97,10 +98,12 @@ function accessKeyToStorageJson(accessKey: AccessKey): AccessKeyStorageJson {
9798
}
9899

99100
function isValidCipher(cipher: string): boolean {
100-
if (["aes-256-gcm", "aes-192-gcm", "aes-128-gcm", "chacha20-ietf-poly1305"].indexOf(cipher) === -1) {
101-
return false;
102-
}
103-
return true;
101+
if (
102+
['aes-256-gcm', 'aes-192-gcm', 'aes-128-gcm', 'chacha20-ietf-poly1305'].indexOf(cipher) === -1
103+
) {
104+
return false;
105+
}
106+
return true;
104107
}
105108

106109
// AccessKeyRepository that keeps its state in a config file and uses ShadowsocksServer
@@ -166,12 +169,13 @@ export class ServerAccessKeyRepository implements AccessKeyRepository {
166169
this.portForNewAccessKeys = port;
167170
}
168171

169-
async createNewAccessKey(encryptionMethod?: string): Promise<AccessKey> {
172+
async createNewAccessKey(params?: AccessKeyCreateParams): Promise<AccessKey> {
170173
const id = this.keyConfig.data().nextId.toString();
171174
this.keyConfig.data().nextId += 1;
172175
const metricsId = uuidv4();
173-
const password = generatePassword();
174-
encryptionMethod = encryptionMethod || this.NEW_USER_ENCRYPTION_METHOD;
176+
const password = params?.password ?? generatePassword();
177+
const encryptionMethod = params?.encryptionMethod || this.NEW_USER_ENCRYPTION_METHOD;
178+
175179
// Validate encryption method.
176180
if (!isValidCipher(encryptionMethod)) {
177181
throw new errors.InvalidCipher(encryptionMethod);
@@ -182,7 +186,9 @@ export class ServerAccessKeyRepository implements AccessKeyRepository {
182186
encryptionMethod,
183187
password,
184188
};
185-
const accessKey = new ServerAccessKey(id, '', metricsId, proxyParams);
189+
const name = params?.name ?? '';
190+
const dataLimit = params?.dataLimit;
191+
const accessKey = new ServerAccessKey(id, name, metricsId, proxyParams, dataLimit);
186192
this.accessKeys.push(accessKey);
187193
this.saveAccessKeys();
188194
await this.updateServer();

0 commit comments

Comments
 (0)