Skip to content

Commit

Permalink
feat(server): extend key creation API to allow specifying a port (#1505)
Browse files Browse the repository at this point in the history
* 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.
  • Loading branch information
sbruens authored Feb 7, 2024
1 parent 0fba65e commit 88cee12
Show file tree
Hide file tree
Showing 7 changed files with 189 additions and 39 deletions.
2 changes: 2 additions & 0 deletions src/shadowbox/model/access_key.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ export interface AccessKeyCreateParams {
readonly password?: string;
// The data transfer limit to apply to the access key.
readonly dataLimit?: DataLimit;
// The port number to use for the access key.
readonly portNumber?: number;
}

export interface AccessKeyRepository {
Expand Down
7 changes: 3 additions & 4 deletions src/shadowbox/model/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,15 +24,14 @@ class OutlineError extends Error {
}

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);
constructor(public port: number) {
super(`Port ${port} is invalid: must be an integer in range [0, 65353]`);
}
}

export class PortUnavailable extends OutlineError {
constructor(public port: number) {
super(port.toString());
super(`Port ${port} is unavailable`);
}
}

Expand Down
8 changes: 4 additions & 4 deletions src/shadowbox/server/api.yml
Original file line number Diff line number Diff line change
Expand Up @@ -154,13 +154,13 @@ paths:
type: string
port:
type: integer
dataLimit:
limit:
$ref: "#/components/schemas/DataLimit"
examples:
'No params specified':
value: '{"method":"aes-192-gcm"}'
'Provide params':
value: '{"method":"aes-192-gcm","name":"First","password":"8iu8V8EeoFVpwQvQeS9wiD","limit":{"bytes":10000}}'
value: '{"method":"aes-192-gcm","name":"First","password":"8iu8V8EeoFVpwQvQeS9wiD","port": 12345,"limit":{"bytes":10000}}'
responses:
'201':
description: The newly created access key
Expand Down Expand Up @@ -196,11 +196,11 @@ paths:
type: string
port:
type: integer
dataLimit:
limit:
$ref: "#/components/schemas/DataLimit"
examples:
'0':
value: '{"id":"123","method":"aes-192-gcm","name":"First","password":"8iu8V8EeoFVpwQvQeS9wiD","limit":{"bytes":10000}}'
value: '{"id":"123","method":"aes-192-gcm","name":"First","password":"8iu8V8EeoFVpwQvQeS9wiD","port": 12345,"limit":{"bytes":10000}}'
responses:
'201':
description: The newly created access key
Expand Down
54 changes: 54 additions & 0 deletions src/shadowbox/server/manager_service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -531,6 +531,59 @@ describe('ShadowsocksManagerService', () => {
done();
});
});

it('uses the default port for new keys when no port is provided', async (done) => {
const res = {
send: (httpCode, data) => {
expect(httpCode).toEqual(201);
expect(data.port).toBeDefined();
responseProcessed = true; // required for afterEach to pass.
},
};
await serviceMethod({params: {id: accessKeyId}}, res, done);
});

it('uses the provided port when one is provided', async (done) => {
const res = {
send: (httpCode, data) => {
expect(httpCode).toEqual(201);
expect(data.port).toEqual(NEW_PORT);
responseProcessed = true; // required for afterEach to pass.
},
};
await serviceMethod({params: {id: accessKeyId, port: NEW_PORT}}, res, done);
});

it('rejects ports that are not numbers', async (done) => {
const res = {send: SEND_NOTHING};
await serviceMethod({params: {id: accessKeyId, port: '1234'}}, res, (error) => {
expect(error.statusCode).toEqual(400);
responseProcessed = true; // required for afterEach to pass.
done();
});
});

it('rejects invalid port numbers', async (done) => {
const res = {send: SEND_NOTHING};
await serviceMethod({params: {id: accessKeyId, port: 1.4}}, res, (error) => {
expect(error.statusCode).toEqual(400);
responseProcessed = true; // required for afterEach to pass.
done();
});
});

it('rejects port numbers already in use', async (done) => {
const server = new net.Server();
server.listen(NEW_PORT, async () => {
const res = {send: SEND_NOTHING};
await serviceMethod({params: {id: accessKeyId, port: NEW_PORT}}, res, (error) => {
expect(error.statusCode).toEqual(409);
responseProcessed = true; // required for afterEach to pass.
server.close();
done();
});
});
});
});
}
});
Expand Down Expand Up @@ -629,6 +682,7 @@ describe('ShadowsocksManagerService', () => {
const server = new net.Server();
server.listen(NEW_PORT, async () => {
await service.setPortForNewAccessKeys({params: {port: NEW_PORT}}, res, next);
server.close();
});
});

Expand Down
52 changes: 28 additions & 24 deletions src/shadowbox/server/manager_service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,7 @@ function validateAccessKeyId(accessKeyId: unknown): string {
return accessKeyId;
}

function validateDataLimit(limit: unknown): DataLimit {
function validateDataLimit(limit: unknown): DataLimit | undefined {
if (typeof limit === 'undefined') {
return undefined;
}
Expand All @@ -204,7 +204,7 @@ function validateDataLimit(limit: unknown): DataLimit {
return limit as DataLimit;
}

function validateStringParam(param: unknown, paramName: string): string {
function validateStringParam(param: unknown, paramName: string): string | undefined {
if (typeof param === 'undefined') {
return undefined;
}
Expand All @@ -218,6 +218,20 @@ function validateStringParam(param: unknown, paramName: string): string {
return param;
}

function validateNumberParam(param: unknown, paramName: string): number | undefined {
if (typeof param === 'undefined') {
return undefined;
}

if (typeof param !== 'number') {
throw new restifyErrors.InvalidArgumentError(
{statusCode: 400},
`Expected a number for ${paramName}, instead got ${param} of type ${typeof param}`
);
}
return param;
}

// The ShadowsocksManagerService manages the access keys that can use the server
// as a proxy using Shadowsocks. It runs an instance of the Shadowsocks server
// for each existing access key, with the port and password assigned for that access key.
Expand Down Expand Up @@ -342,6 +356,7 @@ export class ShadowsocksManagerService {
const name = validateStringParam(req.params.name || '', 'name');
const dataLimit = validateDataLimit(req.params.limit);
const password = validateStringParam(req.params.password, 'password');
const portNumber = validateNumberParam(req.params.port, 'port');

const accessKeyJson = accessKeyToApiJson(
await this.accessKeys.createNewAccessKey({
Expand All @@ -350,13 +365,16 @@ export class ShadowsocksManagerService {
name,
dataLimit,
password,
portNumber,
})
);
return accessKeyJson;
} catch (error) {
logging.error(error);
if (error instanceof errors.InvalidCipher) {
if (error instanceof errors.InvalidCipher || error instanceof errors.InvalidPortNumber) {
throw new restifyErrors.InvalidArgumentError({statusCode: 400}, error.message);
} else if (error instanceof errors.PortUnavailable) {
throw new restifyErrors.ConflictError(error.message);
}
throw error;
}
Expand All @@ -381,10 +399,7 @@ export class ShadowsocksManagerService {
return next();
} catch (error) {
logging.error(error);
if (
error instanceof restifyErrors.InvalidArgumentError ||
error instanceof restifyErrors.MissingParameterError
) {
if (error instanceof restifyErrors.HttpError) {
return next(error);
}
return next(new restifyErrors.InternalServerError());
Expand All @@ -409,10 +424,7 @@ export class ShadowsocksManagerService {
if (error instanceof errors.AccessKeyConflict) {
return next(new restifyErrors.ConflictError(error.message));
}
if (
error instanceof restifyErrors.InvalidArgumentError ||
error instanceof restifyErrors.MissingParameterError
) {
if (error instanceof restifyErrors.HttpError) {
return next(error);
}
return next(new restifyErrors.InternalServerError());
Expand All @@ -427,18 +439,11 @@ export class ShadowsocksManagerService {
): Promise<void> {
try {
logging.debug(`setPortForNewAccessKeys request ${JSON.stringify(req.params)}`);
const port = req.params.port;
if (!port) {
const port = validateNumberParam(req.params.port, 'port');
if (port === undefined) {
return next(
new restifyErrors.MissingParameterError({statusCode: 400}, 'Parameter `port` is missing')
);
} else if (typeof port !== 'number') {
return next(
new restifyErrors.InvalidArgumentError(
{statusCode: 400},
`Expected a numeric port, instead got ${port} of type ${typeof port}`
)
);
}
await this.accessKeys.setPortForNewAccessKeys(port);
this.serverConfig.data().portForNewAccessKeys = port;
Expand All @@ -451,6 +456,8 @@ export class ShadowsocksManagerService {
return next(new restifyErrors.InvalidArgumentError({statusCode: 400}, error.message));
} else if (error instanceof errors.PortUnavailable) {
return next(new restifyErrors.ConflictError(error.message));
} else if (error instanceof restifyErrors.HttpError) {
return next(error);
}
return next(new restifyErrors.InternalServerError(error));
}
Expand Down Expand Up @@ -556,10 +563,7 @@ export class ShadowsocksManagerService {
return next();
} catch (error) {
logging.error(error);
if (
error instanceof restifyErrors.InvalidArgumentError ||
error instanceof restifyErrors.MissingParameterError
) {
if (error instanceof restifyErrors.HttpError) {
return next(error);
}
return next(new restifyErrors.InternalServerError());
Expand Down
71 changes: 71 additions & 0 deletions src/shadowbox/server/server_access_key.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,77 @@ describe('ServerAccessKeyRepository', () => {
});
});

it('createNewAccessKey throws on creating keys with invalid port', async (done) => {
const repo = new RepoBuilder().build();
await expectAsyncThrow(
repo.createNewAccessKey.bind(repo, {portNumber: -123}),
errors.InvalidPortNumber
);
done();
});

it('createNewAccessKey rejects invalid port numbers', async (done) => {
const repo = new RepoBuilder().build();
await expectAsyncThrow(
repo.createNewAccessKey.bind(repo, {portNumber: 0}),
errors.InvalidPortNumber
);
await expectAsyncThrow(
repo.createNewAccessKey.bind(repo, {portNumber: -1}),
errors.InvalidPortNumber
);
await expectAsyncThrow(
repo.createNewAccessKey.bind(repo, {portNumber: 100.1}),
errors.InvalidPortNumber
);
await expectAsyncThrow(
repo.createNewAccessKey.bind(repo, {portNumber: 65536}),
errors.InvalidPortNumber
);
done();
});

it('createNewAccessKey rejects specified ports in use', async (done) => {
const portProvider = new PortProvider();
const port = await portProvider.reserveNewPort();
const repo = new RepoBuilder().build();
const server = new net.Server();
server.listen(port, async () => {
try {
await repo.createNewAccessKey({portNumber: port});
fail(`createNewAccessKey should reject already used port ${port}.`);
} catch (error) {
expect(error instanceof errors.PortUnavailable);
}
server.close();
done();
});
});

it('createNewAccessKey creates keys with the correct default port', async (done) => {
const portProvider = new PortProvider();
const defaultPort = await portProvider.reserveNewPort();
const repo = new RepoBuilder().port(defaultPort).build();
repo.createNewAccessKey().then((accessKey) => {
expect(accessKey).toBeDefined();
expect(accessKey.proxyParams.portNumber).toEqual(defaultPort);
done();
});
});

it('createNewAccessKey creates keys with the port correctly', async (done) => {
const portProvider = new PortProvider();
const defaultPort = await portProvider.reserveNewPort();
const newPort = await portProvider.reserveNewPort();
const repo = new RepoBuilder().port(defaultPort).build();
repo.createNewAccessKey({portNumber: newPort}).then((accessKey) => {
expect(accessKey).toBeDefined();
expect(accessKey.proxyParams.portNumber).not.toEqual(defaultPort);
expect(accessKey.proxyParams.portNumber).toEqual(newPort);
done();
});
});

it('Creates access keys under limit', async (done) => {
const repo = new RepoBuilder().build();
const accessKey = await repo.createNewAccessKey();
Expand Down
Loading

0 comments on commit 88cee12

Please sign in to comment.