Skip to content

Commit 88cee12

Browse files
authored
feat(server): extend key creation API to allow specifying a port (#1505)
* feat(server): extend key creation API to allow specifying a port * Use `validateNumberParam` in `setPortForNewAccessKey`. * Only check if the port is valid if it was specified. * Undo the removal of use of `Promise.all(...)`. * Fix tests. * Only run validation if parameters are provided. * Correct types for API validation methods where `undefined` is a possible return type.
1 parent 0fba65e commit 88cee12

File tree

7 files changed

+189
-39
lines changed

7 files changed

+189
-39
lines changed

src/shadowbox/model/access_key.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,8 @@ export interface AccessKeyCreateParams {
5959
readonly password?: string;
6060
// The data transfer limit to apply to the access key.
6161
readonly dataLimit?: DataLimit;
62+
// The port number to use for the access key.
63+
readonly portNumber?: number;
6264
}
6365

6466
export interface AccessKeyRepository {

src/shadowbox/model/errors.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,15 +24,14 @@ class OutlineError extends Error {
2424
}
2525

2626
export class InvalidPortNumber extends OutlineError {
27-
// Since this is the error when a non-numeric value is passed to `port`, it takes type `string`.
28-
constructor(public port: string) {
29-
super(port);
27+
constructor(public port: number) {
28+
super(`Port ${port} is invalid: must be an integer in range [0, 65353]`);
3029
}
3130
}
3231

3332
export class PortUnavailable extends OutlineError {
3433
constructor(public port: number) {
35-
super(port.toString());
34+
super(`Port ${port} is unavailable`);
3635
}
3736
}
3837

src/shadowbox/server/api.yml

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -154,13 +154,13 @@ paths:
154154
type: string
155155
port:
156156
type: integer
157-
dataLimit:
157+
limit:
158158
$ref: "#/components/schemas/DataLimit"
159159
examples:
160160
'No params specified':
161161
value: '{"method":"aes-192-gcm"}'
162162
'Provide params':
163-
value: '{"method":"aes-192-gcm","name":"First","password":"8iu8V8EeoFVpwQvQeS9wiD","limit":{"bytes":10000}}'
163+
value: '{"method":"aes-192-gcm","name":"First","password":"8iu8V8EeoFVpwQvQeS9wiD","port": 12345,"limit":{"bytes":10000}}'
164164
responses:
165165
'201':
166166
description: The newly created access key
@@ -196,11 +196,11 @@ paths:
196196
type: string
197197
port:
198198
type: integer
199-
dataLimit:
199+
limit:
200200
$ref: "#/components/schemas/DataLimit"
201201
examples:
202202
'0':
203-
value: '{"id":"123","method":"aes-192-gcm","name":"First","password":"8iu8V8EeoFVpwQvQeS9wiD","limit":{"bytes":10000}}'
203+
value: '{"id":"123","method":"aes-192-gcm","name":"First","password":"8iu8V8EeoFVpwQvQeS9wiD","port": 12345,"limit":{"bytes":10000}}'
204204
responses:
205205
'201':
206206
description: The newly created access key

src/shadowbox/server/manager_service.spec.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -531,6 +531,59 @@ describe('ShadowsocksManagerService', () => {
531531
done();
532532
});
533533
});
534+
535+
it('uses the default port for new keys when no port is provided', async (done) => {
536+
const res = {
537+
send: (httpCode, data) => {
538+
expect(httpCode).toEqual(201);
539+
expect(data.port).toBeDefined();
540+
responseProcessed = true; // required for afterEach to pass.
541+
},
542+
};
543+
await serviceMethod({params: {id: accessKeyId}}, res, done);
544+
});
545+
546+
it('uses the provided port when one is provided', async (done) => {
547+
const res = {
548+
send: (httpCode, data) => {
549+
expect(httpCode).toEqual(201);
550+
expect(data.port).toEqual(NEW_PORT);
551+
responseProcessed = true; // required for afterEach to pass.
552+
},
553+
};
554+
await serviceMethod({params: {id: accessKeyId, port: NEW_PORT}}, res, done);
555+
});
556+
557+
it('rejects ports that are not numbers', async (done) => {
558+
const res = {send: SEND_NOTHING};
559+
await serviceMethod({params: {id: accessKeyId, port: '1234'}}, res, (error) => {
560+
expect(error.statusCode).toEqual(400);
561+
responseProcessed = true; // required for afterEach to pass.
562+
done();
563+
});
564+
});
565+
566+
it('rejects invalid port numbers', async (done) => {
567+
const res = {send: SEND_NOTHING};
568+
await serviceMethod({params: {id: accessKeyId, port: 1.4}}, res, (error) => {
569+
expect(error.statusCode).toEqual(400);
570+
responseProcessed = true; // required for afterEach to pass.
571+
done();
572+
});
573+
});
574+
575+
it('rejects port numbers already in use', async (done) => {
576+
const server = new net.Server();
577+
server.listen(NEW_PORT, async () => {
578+
const res = {send: SEND_NOTHING};
579+
await serviceMethod({params: {id: accessKeyId, port: NEW_PORT}}, res, (error) => {
580+
expect(error.statusCode).toEqual(409);
581+
responseProcessed = true; // required for afterEach to pass.
582+
server.close();
583+
done();
584+
});
585+
});
586+
});
534587
});
535588
}
536589
});
@@ -629,6 +682,7 @@ describe('ShadowsocksManagerService', () => {
629682
const server = new net.Server();
630683
server.listen(NEW_PORT, async () => {
631684
await service.setPortForNewAccessKeys({params: {port: NEW_PORT}}, res, next);
685+
server.close();
632686
});
633687
});
634688

src/shadowbox/server/manager_service.ts

Lines changed: 28 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -189,7 +189,7 @@ function validateAccessKeyId(accessKeyId: unknown): string {
189189
return accessKeyId;
190190
}
191191

192-
function validateDataLimit(limit: unknown): DataLimit {
192+
function validateDataLimit(limit: unknown): DataLimit | undefined {
193193
if (typeof limit === 'undefined') {
194194
return undefined;
195195
}
@@ -204,7 +204,7 @@ function validateDataLimit(limit: unknown): DataLimit {
204204
return limit as DataLimit;
205205
}
206206

207-
function validateStringParam(param: unknown, paramName: string): string {
207+
function validateStringParam(param: unknown, paramName: string): string | undefined {
208208
if (typeof param === 'undefined') {
209209
return undefined;
210210
}
@@ -218,6 +218,20 @@ function validateStringParam(param: unknown, paramName: string): string {
218218
return param;
219219
}
220220

221+
function validateNumberParam(param: unknown, paramName: string): number | undefined {
222+
if (typeof param === 'undefined') {
223+
return undefined;
224+
}
225+
226+
if (typeof param !== 'number') {
227+
throw new restifyErrors.InvalidArgumentError(
228+
{statusCode: 400},
229+
`Expected a number for ${paramName}, instead got ${param} of type ${typeof param}`
230+
);
231+
}
232+
return param;
233+
}
234+
221235
// The ShadowsocksManagerService manages the access keys that can use the server
222236
// as a proxy using Shadowsocks. It runs an instance of the Shadowsocks server
223237
// for each existing access key, with the port and password assigned for that access key.
@@ -342,6 +356,7 @@ export class ShadowsocksManagerService {
342356
const name = validateStringParam(req.params.name || '', 'name');
343357
const dataLimit = validateDataLimit(req.params.limit);
344358
const password = validateStringParam(req.params.password, 'password');
359+
const portNumber = validateNumberParam(req.params.port, 'port');
345360

346361
const accessKeyJson = accessKeyToApiJson(
347362
await this.accessKeys.createNewAccessKey({
@@ -350,13 +365,16 @@ export class ShadowsocksManagerService {
350365
name,
351366
dataLimit,
352367
password,
368+
portNumber,
353369
})
354370
);
355371
return accessKeyJson;
356372
} catch (error) {
357373
logging.error(error);
358-
if (error instanceof errors.InvalidCipher) {
374+
if (error instanceof errors.InvalidCipher || error instanceof errors.InvalidPortNumber) {
359375
throw new restifyErrors.InvalidArgumentError({statusCode: 400}, error.message);
376+
} else if (error instanceof errors.PortUnavailable) {
377+
throw new restifyErrors.ConflictError(error.message);
360378
}
361379
throw error;
362380
}
@@ -381,10 +399,7 @@ export class ShadowsocksManagerService {
381399
return next();
382400
} catch (error) {
383401
logging.error(error);
384-
if (
385-
error instanceof restifyErrors.InvalidArgumentError ||
386-
error instanceof restifyErrors.MissingParameterError
387-
) {
402+
if (error instanceof restifyErrors.HttpError) {
388403
return next(error);
389404
}
390405
return next(new restifyErrors.InternalServerError());
@@ -409,10 +424,7 @@ export class ShadowsocksManagerService {
409424
if (error instanceof errors.AccessKeyConflict) {
410425
return next(new restifyErrors.ConflictError(error.message));
411426
}
412-
if (
413-
error instanceof restifyErrors.InvalidArgumentError ||
414-
error instanceof restifyErrors.MissingParameterError
415-
) {
427+
if (error instanceof restifyErrors.HttpError) {
416428
return next(error);
417429
}
418430
return next(new restifyErrors.InternalServerError());
@@ -427,18 +439,11 @@ export class ShadowsocksManagerService {
427439
): Promise<void> {
428440
try {
429441
logging.debug(`setPortForNewAccessKeys request ${JSON.stringify(req.params)}`);
430-
const port = req.params.port;
431-
if (!port) {
442+
const port = validateNumberParam(req.params.port, 'port');
443+
if (port === undefined) {
432444
return next(
433445
new restifyErrors.MissingParameterError({statusCode: 400}, 'Parameter `port` is missing')
434446
);
435-
} else if (typeof port !== 'number') {
436-
return next(
437-
new restifyErrors.InvalidArgumentError(
438-
{statusCode: 400},
439-
`Expected a numeric port, instead got ${port} of type ${typeof port}`
440-
)
441-
);
442447
}
443448
await this.accessKeys.setPortForNewAccessKeys(port);
444449
this.serverConfig.data().portForNewAccessKeys = port;
@@ -451,6 +456,8 @@ export class ShadowsocksManagerService {
451456
return next(new restifyErrors.InvalidArgumentError({statusCode: 400}, error.message));
452457
} else if (error instanceof errors.PortUnavailable) {
453458
return next(new restifyErrors.ConflictError(error.message));
459+
} else if (error instanceof restifyErrors.HttpError) {
460+
return next(error);
454461
}
455462
return next(new restifyErrors.InternalServerError(error));
456463
}
@@ -556,10 +563,7 @@ export class ShadowsocksManagerService {
556563
return next();
557564
} catch (error) {
558565
logging.error(error);
559-
if (
560-
error instanceof restifyErrors.InvalidArgumentError ||
561-
error instanceof restifyErrors.MissingParameterError
562-
) {
566+
if (error instanceof restifyErrors.HttpError) {
563567
return next(error);
564568
}
565569
return next(new restifyErrors.InternalServerError());

src/shadowbox/server/server_access_key.spec.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,77 @@ describe('ServerAccessKeyRepository', () => {
9999
});
100100
});
101101

102+
it('createNewAccessKey throws on creating keys with invalid port', async (done) => {
103+
const repo = new RepoBuilder().build();
104+
await expectAsyncThrow(
105+
repo.createNewAccessKey.bind(repo, {portNumber: -123}),
106+
errors.InvalidPortNumber
107+
);
108+
done();
109+
});
110+
111+
it('createNewAccessKey rejects invalid port numbers', async (done) => {
112+
const repo = new RepoBuilder().build();
113+
await expectAsyncThrow(
114+
repo.createNewAccessKey.bind(repo, {portNumber: 0}),
115+
errors.InvalidPortNumber
116+
);
117+
await expectAsyncThrow(
118+
repo.createNewAccessKey.bind(repo, {portNumber: -1}),
119+
errors.InvalidPortNumber
120+
);
121+
await expectAsyncThrow(
122+
repo.createNewAccessKey.bind(repo, {portNumber: 100.1}),
123+
errors.InvalidPortNumber
124+
);
125+
await expectAsyncThrow(
126+
repo.createNewAccessKey.bind(repo, {portNumber: 65536}),
127+
errors.InvalidPortNumber
128+
);
129+
done();
130+
});
131+
132+
it('createNewAccessKey rejects specified ports in use', async (done) => {
133+
const portProvider = new PortProvider();
134+
const port = await portProvider.reserveNewPort();
135+
const repo = new RepoBuilder().build();
136+
const server = new net.Server();
137+
server.listen(port, async () => {
138+
try {
139+
await repo.createNewAccessKey({portNumber: port});
140+
fail(`createNewAccessKey should reject already used port ${port}.`);
141+
} catch (error) {
142+
expect(error instanceof errors.PortUnavailable);
143+
}
144+
server.close();
145+
done();
146+
});
147+
});
148+
149+
it('createNewAccessKey creates keys with the correct default port', async (done) => {
150+
const portProvider = new PortProvider();
151+
const defaultPort = await portProvider.reserveNewPort();
152+
const repo = new RepoBuilder().port(defaultPort).build();
153+
repo.createNewAccessKey().then((accessKey) => {
154+
expect(accessKey).toBeDefined();
155+
expect(accessKey.proxyParams.portNumber).toEqual(defaultPort);
156+
done();
157+
});
158+
});
159+
160+
it('createNewAccessKey creates keys with the port correctly', async (done) => {
161+
const portProvider = new PortProvider();
162+
const defaultPort = await portProvider.reserveNewPort();
163+
const newPort = await portProvider.reserveNewPort();
164+
const repo = new RepoBuilder().port(defaultPort).build();
165+
repo.createNewAccessKey({portNumber: newPort}).then((accessKey) => {
166+
expect(accessKey).toBeDefined();
167+
expect(accessKey.proxyParams.portNumber).not.toEqual(defaultPort);
168+
expect(accessKey.proxyParams.portNumber).toEqual(newPort);
169+
done();
170+
});
171+
});
172+
102173
it('Creates access keys under limit', async (done) => {
103174
const repo = new RepoBuilder().build();
104175
const accessKey = await repo.createNewAccessKey();

0 commit comments

Comments
 (0)