Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(server): extend key creation API to allow specifying a port #1505

Merged
merged 7 commits into from
Feb 7, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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) {
fortuna marked this conversation as resolved.
Show resolved Hide resolved
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
Loading