Allow users to change the hostname for new access keys (#565)

* Create an endpoint to change the server's hostname.

This is the backend only, and does not check if the hostname exists or points to a Shadowbox
server.  Users have the responsibility of double checking that the
hostname and DNS if necessary are properly set up and specified.

* Updates

Changes validation to use regex.

Gets rid of a bad test

Uses paramtereized tests for better test output

Adds setHostname to AccessKeyRepository and calls it in the endpoint

* Integration tests

Also fixes missing dependency on ip-regex

* Remove validator from lockfile

* Ensure we correctly use Python 2 for Cloud SDK

* API for the new endpoint to change the server hostname. (#534)

* API for the new endpoint to change the server hostname.

I am sending it up now so we can iterate on it while I'm busy
implementing the endpoint.  I will merge it into the feature branch when
it's done as opposed as into master.

* API for the new endpoint to change the server hostname.

I am sending it up now so we can iterate on it while I'm busy
implementing the endpoint.  I will merge it into the feature branch when
it's done as opposed as into master.

* Remove 404 and 406 error states.  Checking for a functioning Shadowbox
server requires knowledge of the API prefix, which creates layering
challenges.  The UI will be able to make these checks, but users who
call the API directly will have to be careful not to put in an incorrect
hostname.  The consequence of this would be that newly made access keys
won't work.

* Clarify API, change name, add validation errors

* Clarify description

* Remove the API response corresponding to a failed check against the new
hostname that it points to the same server

* Respond to review comments

* Rename endpoint and related functions

From the server's perspective, this does change the hostname for
existing access keys since on server load time (in the Server Manager)
we recalculate access key hostnames from the key repository.

* Respond to review comments

* Use version 1.2.0 since data limits is taking 1.1.0

* Fix update_mmdb and the Docker image to work on Alpine (#559)

* Fix the for loop in update_mmdb to be `dash` compatible

for x in {a..b} is bash-only, and alpine's default shell is `dash`.

* Fix other issues related to using musl/Busybox/dash

* Use `[[` instead of `((` for the branch
* Make sure to update to GNU coreutils in the image in order to use
`date --date`

* Respond to review comments

* UI for setting the hostname for access key invitations (#553)

* Increase Shadowbox version for data limits experiment (#544)

* Return the port for new access keys from GET /server.  (#552)

This was accidentally removed in #537.

* UI for setting the hostname for access key invitations

This also includes some random typo fixes and QOL improvements.

To test, you can use

    export SB_IMAGE=cohenjongoogle/shadowbox:latest
    yarn do shadowbox/docker/run

then in a different terminal

    yarn do server_manager/electron_app/run

and add the server

* Add back in new files

Also fixes a backward compatibility issues for servers which don't
support changing the hostname

* Respond to review comments

* Respond to review comments

* Respond to review comments

* Respond to review comments

* Use DB-IP's Geolocation database instead of MaxMind's (#557)

* Use DB-IP's free IP-to-Country database instead of MaxMind's

This avoids the necesseity to either (1) bundle MaxMind's database in
our docker image or (2) require server admins to individually create
MaxMind accounts due to the new requirements for using GeoLite2
databases.  See https://blog.maxmind.com/2019/12/18/significant-changes-to-accessing-and-using-geolite2-databases/

* Add attribution to DB-IP for the GeoIP database

* Make sure that we deal with lag between the changeover of the current
month and release of new versions of the DB-IP database

* Respond to review comments

* Attribute DB-IP in the update script

* Respond to review comments

* Respond to review comments

* Fix update_mmdb and the Docker image to work on Alpine (#559)

* Fix the for loop in update_mmdb to be `dash` compatible

for x in {a..b} is bash-only, and alpine's default shell is `dash`.

* Fix other issues related to using musl/Busybox/dash

* Use `[[` instead of `((` for the branch
* Make sure to update to GNU coreutils in the image in order to use
`date --date`

* Respond to review comments

* Update to Typescript 3.7 (#558)

See https://www.typescriptlang.org/docs/handbook/release-notes/typescript-3-7.html for more information.  Among the nice new features are optional chainging and null coalescing, and assertion functions, as well as some additional safety checks in the compiler

* Create an endpoint to change the server's hostname.

This is the backend only, and does not check if the hostname exists or points to a Shadowbox
server.  Users have the responsibility of double checking that the
hostname and DNS if necessary are properly set up and specified.

* Updates

Changes validation to use regex.

Gets rid of a bad test

Uses paramtereized tests for better test output

Adds setHostname to AccessKeyRepository and calls it in the endpoint

* Integration tests

Also fixes missing dependency on ip-regex

* Remove validator from lockfile

* Ensure we correctly use Python 2 for Cloud SDK

* API for the new endpoint to change the server hostname. (#534)

* API for the new endpoint to change the server hostname.

I am sending it up now so we can iterate on it while I'm busy
implementing the endpoint.  I will merge it into the feature branch when
it's done as opposed as into master.

* API for the new endpoint to change the server hostname.

I am sending it up now so we can iterate on it while I'm busy
implementing the endpoint.  I will merge it into the feature branch when
it's done as opposed as into master.

* Remove 404 and 406 error states.  Checking for a functioning Shadowbox
server requires knowledge of the API prefix, which creates layering
challenges.  The UI will be able to make these checks, but users who
call the API directly will have to be careful not to put in an incorrect
hostname.  The consequence of this would be that newly made access keys
won't work.

* Clarify API, change name, add validation errors

* Clarify description

* Remove the API response corresponding to a failed check against the new
hostname that it points to the same server

* Respond to review comments

* Rename endpoint and related functions

From the server's perspective, this does change the hostname for
existing access keys since on server load time (in the Server Manager)
we recalculate access key hostnames from the key repository.

* Respond to review comments

* Use version 1.2.0 since data limits is taking 1.1.0

* Fix update_mmdb and the Docker image to work on Alpine (#559)

* Fix the for loop in update_mmdb to be `dash` compatible

for x in {a..b} is bash-only, and alpine's default shell is `dash`.

* Fix other issues related to using musl/Busybox/dash

* Use `[[` instead of `((` for the branch
* Make sure to update to GNU coreutils in the image in order to use
`date --date`

* Respond to review comments

* UI for setting the hostname for access key invitations

This also includes some random typo fixes and QOL improvements.

To test, you can use

    export SB_IMAGE=cohenjongoogle/shadowbox:latest
    yarn do shadowbox/docker/run

then in a different terminal

    yarn do server_manager/electron_app/run

and add the server

* Add back in new files

Also fixes a backward compatibility issues for servers which don't
support changing the hostname

* Respond to review comments

* Respond to review comments

* Respond to review comments

* Respond to review comments

* Fix messed up rebase

* merge

* Clear merge markers

Co-authored-by: alalamav <2132122+alalamav@users.noreply.github.com>

* Create an endpoint to change the server's hostname.

This is the backend only, and does not check if the hostname exists or points to a Shadowbox
server.  Users have the responsibility of double checking that the
hostname and DNS if necessary are properly set up and specified.

* Updates

Changes validation to use regex.

Gets rid of a bad test

Uses paramtereized tests for better test output

Adds setHostname to AccessKeyRepository and calls it in the endpoint

* Integration tests

Also fixes missing dependency on ip-regex

* Remove validator from lockfile

* Ensure we correctly use Python 2 for Cloud SDK

* API for the new endpoint to change the server hostname. (#534)

* API for the new endpoint to change the server hostname.

I am sending it up now so we can iterate on it while I'm busy
implementing the endpoint.  I will merge it into the feature branch when
it's done as opposed as into master.

* API for the new endpoint to change the server hostname.

I am sending it up now so we can iterate on it while I'm busy
implementing the endpoint.  I will merge it into the feature branch when
it's done as opposed as into master.

* Remove 404 and 406 error states.  Checking for a functioning Shadowbox
server requires knowledge of the API prefix, which creates layering
challenges.  The UI will be able to make these checks, but users who
call the API directly will have to be careful not to put in an incorrect
hostname.  The consequence of this would be that newly made access keys
won't work.

* Clarify API, change name, add validation errors

* Clarify description

* Remove the API response corresponding to a failed check against the new
hostname that it points to the same server

* Respond to review comments

* Rename endpoint and related functions

From the server's perspective, this does change the hostname for
existing access keys since on server load time (in the Server Manager)
we recalculate access key hostnames from the key repository.

* Respond to review comments

* Use version 1.2.0 since data limits is taking 1.1.0

* UI for setting the hostname for access key invitations (#553)

* Increase Shadowbox version for data limits experiment (#544)

* Return the port for new access keys from GET /server.  (#552)

This was accidentally removed in #537.

* UI for setting the hostname for access key invitations

This also includes some random typo fixes and QOL improvements.

To test, you can use

    export SB_IMAGE=cohenjongoogle/shadowbox:latest
    yarn do shadowbox/docker/run

then in a different terminal

    yarn do server_manager/electron_app/run

and add the server

* Add back in new files

Also fixes a backward compatibility issues for servers which don't
support changing the hostname

* Respond to review comments

* Respond to review comments

* Respond to review comments

* Respond to review comments

* Use DB-IP's Geolocation database instead of MaxMind's (#557)

* Use DB-IP's free IP-to-Country database instead of MaxMind's

This avoids the necesseity to either (1) bundle MaxMind's database in
our docker image or (2) require server admins to individually create
MaxMind accounts due to the new requirements for using GeoLite2
databases.  See https://blog.maxmind.com/2019/12/18/significant-changes-to-accessing-and-using-geolite2-databases/

* Add attribution to DB-IP for the GeoIP database

* Make sure that we deal with lag between the changeover of the current
month and release of new versions of the DB-IP database

* Respond to review comments

* Attribute DB-IP in the update script

* Respond to review comments

* Respond to review comments

* Fix update_mmdb and the Docker image to work on Alpine (#559)

* Fix the for loop in update_mmdb to be `dash` compatible

for x in {a..b} is bash-only, and alpine's default shell is `dash`.

* Fix other issues related to using musl/Busybox/dash

* Use `[[` instead of `((` for the branch
* Make sure to update to GNU coreutils in the image in order to use
`date --date`

* Respond to review comments

* Update to Typescript 3.7 (#558)

See https://www.typescriptlang.org/docs/handbook/release-notes/typescript-3-7.html for more information.  Among the nice new features are optional chainging and null coalescing, and assertion functions, as well as some additional safety checks in the compiler

* Create an endpoint to change the server's hostname.

This is the backend only, and does not check if the hostname exists or points to a Shadowbox
server.  Users have the responsibility of double checking that the
hostname and DNS if necessary are properly set up and specified.

* Updates

Changes validation to use regex.

Gets rid of a bad test

Uses paramtereized tests for better test output

Adds setHostname to AccessKeyRepository and calls it in the endpoint

* Integration tests

Also fixes missing dependency on ip-regex

* Remove validator from lockfile

* Ensure we correctly use Python 2 for Cloud SDK

* API for the new endpoint to change the server hostname. (#534)

* API for the new endpoint to change the server hostname.

I am sending it up now so we can iterate on it while I'm busy
implementing the endpoint.  I will merge it into the feature branch when
it's done as opposed as into master.

* API for the new endpoint to change the server hostname.

I am sending it up now so we can iterate on it while I'm busy
implementing the endpoint.  I will merge it into the feature branch when
it's done as opposed as into master.

* Remove 404 and 406 error states.  Checking for a functioning Shadowbox
server requires knowledge of the API prefix, which creates layering
challenges.  The UI will be able to make these checks, but users who
call the API directly will have to be careful not to put in an incorrect
hostname.  The consequence of this would be that newly made access keys
won't work.

* Clarify API, change name, add validation errors

* Clarify description

* Remove the API response corresponding to a failed check against the new
hostname that it points to the same server

* Respond to review comments

* Rename endpoint and related functions

From the server's perspective, this does change the hostname for
existing access keys since on server load time (in the Server Manager)
we recalculate access key hostnames from the key repository.

* Respond to review comments

* Use version 1.2.0 since data limits is taking 1.1.0

* Fix update_mmdb and the Docker image to work on Alpine (#559)

* Fix the for loop in update_mmdb to be `dash` compatible

for x in {a..b} is bash-only, and alpine's default shell is `dash`.

* Fix other issues related to using musl/Busybox/dash

* Use `[[` instead of `((` for the branch
* Make sure to update to GNU coreutils in the image in order to use
`date --date`

* Respond to review comments

* UI for setting the hostname for access key invitations

This also includes some random typo fixes and QOL improvements.

To test, you can use

    export SB_IMAGE=cohenjongoogle/shadowbox:latest
    yarn do shadowbox/docker/run

then in a different terminal

    yarn do server_manager/electron_app/run

and add the server

* Add back in new files

Also fixes a backward compatibility issues for servers which don't
support changing the hostname

* Respond to review comments

* Respond to review comments

* Respond to review comments

* Respond to review comments

* Fix messed up rebase

* merge

* Clear merge markers

Co-authored-by: alalamav <2132122+alalamav@users.noreply.github.com>

Co-authored-by: alalamav <2132122+alalamav@users.noreply.github.com>
This commit is contained in:
Jonathan Cohen 2020-01-30 15:22:57 -05:00 committed by GitHub
parent 4b95dbd23f
commit dafa1f5bc2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
24 changed files with 590 additions and 195 deletions

1
.gitignore vendored
View file

@ -4,3 +4,4 @@
node_modules/
/src/server_manager/install_scripts/do_install_script.ts
yarn-error.log
.vscode/

View file

@ -36,6 +36,7 @@
"error-do-auth": "Authentication with DigitalOcean failed",
"error-do-regions": "Failed to get list of available regions",
"error-feedback": "Failed to submit feedback. Please try again.",
"error-hostname-invalid": "Must be an IP address or valid hostname.",
"error-key-add": "Failed to add key",
"error-key-remove": "Failed to remove key",
"error-key-rename": "Failed to rename key",

View file

@ -189,6 +189,10 @@
"message": "Failed to submit feedback. Please try again.",
"description": "This string appears in an error notification toast. It is shown when there is an error submitting the user's feedback."
},
"error_hostname_invalid": {
"message": "Must be an IP address or valid hostname.",
"description": "This string appears in an inline error message. It signifies that the user has input an invalid hostname."
},
"error_key_add": {
"message": "Failed to add key",
"description": "This string appears in an error notification toast. It is shown when there is an error creating a server access key."

View file

@ -53,7 +53,10 @@ export interface Server {
getCreatedDate(): Date;
// Returns the server's domain name or IP address.
getHostname(): string;
getHostnameForAccessKeys(): string;
// Changes the hostname for shared access keys.
setHostnameForAccessKeys(hostname: string): Promise<void>;
// Returns the server's management API URL.
getManagementApiUrl(): string;

View file

@ -0,0 +1,71 @@
<!--
Copyright 2020 The Outline Authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<!-- outline-server-settings-styles
This file holds common styles for outline-server-settings and outline-validated-input
-->
<dom-module id='outline-server-settings-styles'>
<template>
<style>
/* Skip processing these with postcss-rtl as it incorrectly parses the border-color
in the paper-input-container-underline-focus mixin.
https://github.com/vkalinichev/postcss-rtl/issues/50 */
/* rtl:begin:ignore */
paper-input {
/* Removes extra padding added by children of paper-input */
margin-top: -8px;
/* Create space for error messages */
margin-bottom: 14px;
--paper-input-container-label-focus: {
color: var(--primary-green);
};
--paper-input-container-underline-focus: {
border-color: var(--primary-green);
};
--paper-input-container-label: {
font-size: 14px;
line-height: 22px;
};
--paper-input-container-color: var(--medium-gray);
--paper-input-container-input: {
color: #fff;
};
--paper-input-container-invalid-color: #f28b82;
}
/* rtl:end:ignore */
paper-input[readonly] {
--paper-input-container-underline: {
display: none;
};
--paper-input-container-underline-focus: {
display: none;
};
--paper-input-container-underline-disabled: {
display: none;
};
--paper-input-container-disabled: {
opacity: 1;
}
}
paper-input:not([readonly]) {
width: 60%;
}
</style>
</template>
</dom-module>

View file

@ -19,11 +19,14 @@
<link rel='import' href='../bower_components/paper-checkbox/paper-checkbox.html'>
<link rel='import' href='./cloud-install-styles.html'>
<link rel='import' href='./outline-server-settings-styles.html'>
<link rel='import' href='./outline-iconset.html'>
<link rel='import' href='./outline-validated-input.html'>
<dom-module id='outline-server-settings'>
<template>
<style include='cloud-install-styles'></style>
<style include='outline-server-settings-styles'></style>
<style>
.content {
flex-grow: 1;
@ -60,11 +63,6 @@
justify-content: space-between;
align-items: flex-start;
}
#portForNewAccessKeysContainer {
display: flex;
justify-content: space-between;
align-items: center;
}
.detail {
margin-top: 0px;
font-size: 12px;
@ -72,27 +70,6 @@
.clickable {
cursor: pointer;
}
#keysPortSaveButton {
background: var(--primary-green);
color: #fff;
height: 36px;
width: 83px;
}
#keysPortSaveButton[disabled] {
background-color: rgba(255, 255, 255, .12);
color: rgba(255, 255, 255, .3);
}
#keysPortCancelButton {
color: #fff;
width: 83px;
}
#keysPortCancelButton[disabled] {
color:rgba(255, 255, 255, 0.3);
background-color: rgba(0, 0, 0, 0);
}
#portForNewAccessKeysContainer {
max-width: 545px;
}
paper-checkbox {
/* We want the ink to be the color we're going to, not coming from */
--paper-checkbox-checked-color: var(--primary-green);
@ -100,52 +77,10 @@
--paper-checkbox-unchecked-color: var(--light-gray);
--paper-checkbox-unchecked-ink-color: var(--primary-green);
}
/* Skip processing these with postcss-rtl as it incorrectly parses the border-color
in the paper-input-container-underline-focus mixin. https://github.com/vkalinichev/postcss-rtl/issues/50 */
/* rtl:begin:ignore */
paper-input {
/* Removes extra padding added by children of paper-input */
margin-top: -8px;
/* Create space for error messages */
margin-bottom: 14px;
--paper-input-container-label-focus: {
color: var(--primary-green);
};
--paper-input-container-underline-focus: {
border-color: var(--primary-green);
};
--paper-input-container-label: {
font-size: 14px;
line-height: 22px;
};
--paper-input-container-color: var(--medium-gray);
--paper-input-container-input: {
color: #fff;
};
--paper-input-container-invalid-color: #f28b82;
}
/* rtl:end:ignore */
paper-input[readonly] {
--paper-input-container-underline: {
display: none;
};
--paper-input-container-underline-focus: {
display: none;
};
--paper-input-container-underline-disabled: {
display: none;
};
--paper-input-container-disabled: {
opacity: 1;
}
}
.link {
text-decoration: underline;
color: var(--medium-gray);
}
paper-input:not([readonly]) {
width: 60%;
}
</style>
<div class="container">
<div class="content">
@ -165,12 +100,12 @@
<h3>[[localize('settings-server-info')]]</h3>
<paper-input id="serverNameInput" class="server-name" value="{{name}}" label="[[localize('settings-server-name')]]" always-float-label maxlength="100" on-keydown="_handleNameInputKeyDown" on-blur="_handleNameInputBlur"></paper-input>
<p class="detail">[[localize('settings-server-rename')]]</p>
<div id="portForNewAccessKeysContainer">
<paper-input id="keysPortInput" readonly$="[[!isAccessKeyPortEditable]]" allowed-pattern="[0-9]{1,5}" value="[[serverPortForNewAccessKeys]]" label="[[localize('settings-access-key-port')]]" maxlength="5" hidden$="[[!serverPortForNewAccessKeys]]" on-tap="setKeysPortEditing" on-keyup="_handleKeysPortInputKeyUp" always-float-label maxlength="100"></paper-input>
<paper-button id="keysPortCancelButton" hidden$="[[!showKeysPortButtons]]" disabled$="[[!enableKeysPortButtons]]" on-tap="cancelKeysPortEdit">[[localize('cancel')]]</paper-button>
<paper-button id="keysPortSaveButton" hidden$="[[!showKeysPortButtons]]" disabled$="[[!enableKeysPortButtons]]" on-tap="saveKeysPort">[[localize('save')]]</paper-button>
</div>
<paper-input readonly value="[[serverHostname]]" label="[[localize('settings-server-hostname')]]" hidden$="[[!serverHostname]]" always-float-label maxlength="100"></paper-input>
<outline-validated-input
editable="[[isAccessKeyPortEditable]]" visible="[[serverPortForNewAccessKeys]]" label="[[localize('settings-access-key-port')]]" allowed-pattern="[0-9]{1,5}" max-length="5" value="[[serverPortForNewAccessKeys]]" client-side-validator="[[_validatePort]]" event="ChangePortForNewAccessKeysRequested" localize="[[localize]]"
></outline-validated-input>
<outline-validated-input
editable="[[isHostnameEditable]]" visible="[[serverHostname]]" label="[[localize('settings-server-hostname')]]" max-length="253" value="[[serverHostname]]" event="ChangeHostnameForAccessKeysRequested" localize="[[localize]]"
></outline-validated-input>
<paper-input readonly value="[[serverManagementApiUrl]]" label="[[localize('settings-server-api-url')]]" hidden$="[[!serverManagementApiUrl]]" always-float-label maxlength="100"></paper-input>
<paper-input readonly value="[[serverCreationDate]]" label="[[localize('settings-server-creation')]]" hidden$="[[!serverCreationDate]]" always-float-label maxlength="100"></paper-input>
<paper-input readonly value="[[serverId]]" label="[[localize('settings-server-id')]]" hidden$="[[!serverId]]" always-float-label maxlength="100"></paper-input>
@ -203,6 +138,7 @@
serverManagementApiUrl: {type: String, value: null},
serverPortForNewAccessKeys: {type: Number, value: null},
isAccessKeyPortEditable: {type: Boolean, value: false},
isHostnameEditable: {type: Boolean, value: true},
serverCreationDate: {type: String, value: null},
serverLocation: {type: String, value: null},
serverMonthlyCost: {type: String, value: null},
@ -247,71 +183,11 @@
this.fire(metricsSignal);
}
},
setKeysPortEditing: function() {
if (!this.isAccessKeyPortEditable) {
return;
}
this.showKeysPortButtons = true;
const invalid = this.$.keysPortInput.invalid;
this.$.keysPortSaveButton.disabled = invalid;
this.$.keysPortCancelButton.disabled = false;
},
setKeysPortSaved: function() {
this.serverPortForNewAccessKeys = Number(this.$.keysPortInput.value);
this.showKeysPortButtons = false;
this.$.keysPortInput.readonly = false;
},
setKeysPortErrorState: function(message) {
const input = this.$.keysPortInput;
this.enableKeysPortButtons = true;
input.invalid = true;
input.errorMessage = message;
input.readonly = false;
input.focus();
},
saveKeysPort: function() {
const input = this.$.keysPortInput;
if (Number(input.value) === this.serverPortForNewAccessKeys) {
this.cancelKeysPortEdit();
return;
};
this.enableKeysPortButtons = false;
input.readonly = true;
input.invalid = false;
this.fire('ChangePortForNewAccessKeysRequested', {
port: Number(this.$.keysPortInput.value),
serverSettings: this
});
},
cancelKeysPortEdit: function() {
this.$.keysPortInput.value = this.serverPortForNewAccessKeys;
this.$.keysPortInput.invalid = false;
this.showKeysPortButtons = false;
},
_handleKeysPortInputKeyUp: function(e) {
let input = this.$.keysPortInput;
const badInputMessage = this.localize('error-keys-port-bad-input');
if (e.key === 'Escape') {
this.cancelKeysPortEdit();
input.blur();
return;
} else if (e.key === 'Enter') {
if (!input.invalid) {
this.saveKeysPort();
input.blur();
}
return;
}
let port = Number(input.value);
if (Number.isNaN(port) || (port < 1 || port > 65535) || !Number.isInteger(port)) {
input.invalid = true;
this.$.keysPortSaveButton.disabled = true;
input.errorMessage = badInputMessage;
} else {
input.invalid = false;
this.$.keysPortSaveButton.disabled = false;
}
}
_validatePort: function(value) {
const port = Number(value);
const valid = !Number.isNaN(port) && port >= 1 && port <= 65535 && Number.isInteger(port);
return valid ? "" : this.localize("error-keys-port-bad-input");
}
});
</script>
</dom-module>

View file

@ -499,6 +499,7 @@
id="serverSettings"
server-id="[[serverId]]"
server-hostname="[[serverHostname]]"
is-hostname-editable="[[isHostnameEditable]]"
server-management-api-url="[[serverManagementApiUrl]]"
server-port-for-new-access-keys="[[serverPortForNewAccessKeys]]"
is-access-key-port-editable="[[isAccessKeyPortEditable]]"
@ -539,6 +540,7 @@
serverId: String,
serverName: String,
serverHostname: String,
isHostnameEditable: {type: Boolean, value: false},
serverManagementApiUrl: String,
serverPortForNewAccessKeys: Number,
isAccessKeyPortEditable: {type: Boolean, value: false},

View file

@ -0,0 +1,191 @@
<!--
Copyright 2020 The Outline Authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<!-- outline-validated-input
This is an input, with a cancel and a save button, which performs client-side validation and has an
event-based hook for server-side evaluation.
Attributes:
* editable: Is this input editable? Default: false
* visible: Is this input visible? Default: false
* label: The label for the input. Default: null
* value: The initial value entered in the input. SHOULD be a valid value. Default: null
* allowed-pattern: Regex describing what inputs are allowed. Users will be prevented from
entering inputs which don't follow the pattern. Default: ".*"
* max-length: The number of characters allowed in the input. Default: Number.POSITIVE_INFINITY
* client-side-validator: A function which takes a string and returns either an empty string on success
or an error message on failure. This function will be called on every keystroke. Default: () => ""
* event: The name of the event fired when the save button is tapped. Passes the
current input value as "value" and the Polymer object as "ui". The handler for this event
MUST call ui.setSavedState() or ui.setErrorState(message) depending on its result. Default: null
-->
<link rel='import' href='./cloud-install-styles.html'>
<link rel='import' href='./outline-server-settings-styles.html'>
<dom-module id='outline-validated-input'>
<template>
<style include='cloud-install-styles'></style>
<style include='outline-server-settings-styles'></style>
<style>
#container {
display: flex;
justify-content: space-between;
align-items: center;
max-width: 545px;
}
paper-button {
height: 36px;
width: 83px;
text-align:center
}
paper-button[disabled] {
color: rgba(255, 255, 255, 0.3);
}
#saveButton {
background: var(--primary-green);
color: #fff;
}
#cancelButton {
color: #fff;
}
#saveButton[disabled] {
background-color: rgba(255, 255, 255, .12);
}
#cancelButton[disabled] {
background-color: rgba(0, 0, 0, 0);
}
</style>
<div id="container" hidden$="[[!visible]]">
<!-- We use on-focus instead of on-tap to handle the case where text is selected but the tap event isn't fired. -->
<paper-input id="input" readonly$="[[!editable]]" allowed-pattern="[[allowedPattern]]" value="[[value]]"
label="[[label]]" maxlength="[[maxLength]]" on-focus="_enterEditingState" on-keyup="_onKeyUp"
always-float-label>
</paper-input>
<paper-button id="cancelButton" hidden$="[[!_showButtons]]" disabled$="[[!_enableButtons]]"
on-tap="_cancel">
[[localize('cancel')]]
</paper-button>
<paper-button id="saveButton" hidden$="[[!_showButtons]]" disabled$="[[!_enableButtons]]" on-tap="_save">
[[localize('save')]]
</paper-button>
</div>
</template>
<script>
Polymer({
is: 'outline-validated-input',
properties: {
// Properties affecting the whole element
editable: { type: Boolean, value: false },
visible: { type: Boolean, value: false },
// Properties affecting the input
label: { type: String, readonly: true, value: null },
allowedPattern: { type: String, value: ".*" },
maxLength: { type: Number, value: Number.POSITIVE_INFINITY },
value: { type: String, value: null },
// `value` here is evaluated. If it were simply `() => "`, then clientSideValidator
// would default to just "", not a function returning "".
// Note also that we can't use paper-input's validator attribute because it will fight
// with any server-side validation and cause unpredictable results.
clientSideValidator: { type: Function, readonly: true, value: () => { return () => ""; } },
// Properties affecting the buttons
_showButtons: { type: Boolean, value: false },
_enableButtons: { type: Boolean, value: false },
// Other properties
event: { type: String, value: null },
localize: { type: Function, readonly: true }
},
_onKeyUp: function (e) {
const input = this.$.input;
if (e.key === 'Escape') {
this._cancel();
input.blur();
return;
} else if (e.key === 'Enter') {
if (!input.invalid) {
this._save();
input.blur();
}
return;
}
const validationError = this.clientSideValidator(input.value);
if (validationError) {
input.invalid = true;
this.$.saveButton.disabled = true;
input.errorMessage = validationError;
} else {
input.invalid = false;
this.$.saveButton.disabled = false;
}
},
_cancel: function () {
const input = this.$.input;
input.value = this.value;
input.invalid = false;
this._showButtons = false;
},
_save: function () {
const input = this.$.input;
const value = input.value;
if (value === this.value) {
this._cancel();
return;
};
this.$.cancelButton.disabled = true;
this.$.saveButton.disabled = true;
input.readonly = true;
input.invalid = false;
// We use this heuristic to avoid having to pass a constructor as an attribute
const numberValue = Number(value);
const typedValue = Number.isNaN(numberValue) ? value : numberValue;
this.fire(this.event, {
validatedInput: typedValue,
ui: this
});
},
_enterEditingState: function () {
if (!this.editable) {
return;
}
this._showButtons = true;
this.$.cancelButton.disabled = false;
this.$.saveButton.disabled = this.$.input.invalid;
},
enterSavedState: function () {
const input = this.$.input;
this.value = input.value;
this._showButtons = false;
input.readonly = false;
},
enterErrorState: function (message) {
const input = this.$.input;
this._enableButtons = true;
input.errorMessage = message;
input.invalid = true;
input.readonly = false;
input.focus();
}
});
</script>
</dom-module>

View file

@ -369,7 +369,10 @@ class FakeServer implements server.Server {
removeAccessKey(accessKeyId: server.AccessKeyId) {
return Promise.reject(new Error('FakeServer.removeAccessKey not implemented'));
}
getHostname() {
setHostnameForAccessKeys(hostname: string) {
return Promise.reject(new Error("FakeServer.setHostname not implemented"));
}
getHostnameForAccessKeys() {
return 'fake-server';
}
getManagementApiUrl() {

View file

@ -38,6 +38,7 @@ interface PolymerEvent extends Event {
const UNUSED_DIGITALOCEAN_REFERRAL_CODE = '5ddb4219b716';
const CHANGE_KEYS_PORT_VERSION = "1.0.0";
const CHANGE_HOSTNAME_VERSION = "1.2.0";
interface UiAccessKey {
id: string;
@ -110,7 +111,11 @@ export class App {
});
appRoot.addEventListener('ChangePortForNewAccessKeysRequested', (event: PolymerEvent) => {
this.setPortForNewAccessKeys(event.detail.port, event.detail.serverSettings);
this.setPortForNewAccessKeys(event.detail.validatedInput, event.detail.ui);
});
appRoot.addEventListener('ChangeHostnameForAccessKeysRequested', (event: PolymerEvent) => {
this.setHostnameForAccessKeys(event.detail.validatedInput, event.detail.ui);
});
// The UI wants us to validate a server management URL.
@ -761,11 +766,12 @@ export class App {
view.isServerReachable = true;
view.serverId = selectedServer.getServerId();
view.serverName = selectedServer.getName();
view.serverHostname = selectedServer.getHostname();
view.serverHostname = selectedServer.getHostnameForAccessKeys();
view.serverManagementApiUrl = selectedServer.getManagementApiUrl();
view.serverPortForNewAccessKeys = selectedServer.getPortForNewAccessKeys();
const version = this.selectedServer.getVersion();
view.isAccessKeyPortEditable = version && semver.gte(version, CHANGE_KEYS_PORT_VERSION);
view.isHostnameEditable = version && semver.gte(version, CHANGE_HOSTNAME_VERSION);
view.serverCreationDate = selectedServer.getCreatedDate().toLocaleString(
this.appRoot.language, {year: 'numeric', month: 'long', day: 'numeric'});
@ -920,24 +926,41 @@ export class App {
});
}
private async setHostnameForAccessKeys(hostname: string, serverSettings: Polymer) {
this.appRoot.showNotification(this.appRoot.localize("saving"));
try {
await this.selectedServer.setHostnameForAccessKeys(hostname);
this.appRoot.showNotification(this.appRoot.localize("saved"));
serverSettings.enterSavedState();
} catch (error) {
this.appRoot.showError(this.appRoot.localize("error-not-saved"));
if (error.isNetworkError()) {
serverSettings.enterErrorState(this.appRoot.localize("error-network"));
return;
}
const message = error.response.status === 400 ? "error-hostname-invalid" : "error-unexpected";
serverSettings.enterErrorState(this.appRoot.localize(message));
}
}
private async setPortForNewAccessKeys(port: number, serverSettings: Polymer) {
this.appRoot.showNotification(this.appRoot.localize("saving"));
try {
await this.selectedServer.setPortForNewAccessKeys(port);
this.appRoot.showNotification(this.appRoot.localize("saved"));
serverSettings.setKeysPortSaved();
serverSettings.enterSavedState();
} catch (error) {
this.appRoot.showError(this.appRoot.localize("error-not-saved"));
if (error.isNetworkError()) {
serverSettings.setKeysPortErrorState(this.appRoot.localize("error-network"));
serverSettings.enterErrorState(this.appRoot.localize("error-network"));
return;
}
const code = error.response.status;
if (code === 409) {
serverSettings.setKeysPortErrorState(this.appRoot.localize("error-keys-port-in-use"));
serverSettings.enterErrorState(this.appRoot.localize("error-keys-port-in-use"));
return;
}
serverSettings.setKeysPortErrorState(this.appRoot.localize("error-unexpected"));
serverSettings.enterErrorState(this.appRoot.localize("error-unexpected"));
}
}

View file

@ -25,7 +25,7 @@ export interface DisplayServer {
export async function makeDisplayServer(server: server.Server) {
return {
id: server.getManagementApiUrl(),
name: await server.isHealthy().catch((e) => false) ? server.getName() : server.getHostname(),
name: await server.isHealthy().catch((e) => false) ? server.getName() : server.getHostnameForAccessKeys(),
isManaged: !!(server as server.ManagedServer).getHost,
isSynced: true
};

View file

@ -28,6 +28,7 @@ export interface ServerConfig {
serverId: string;
createdTimestampMs: number;
portForNewAccessKeys: number;
hostnameForAccessKeys: string;
version: string;
}
@ -129,12 +130,26 @@ export class ShadowboxServer implements server.Server {
return new Date(this.serverConfig.createdTimestampMs);
}
getHostname(): string {
async setHostnameForAccessKeys(hostname: string): Promise<void> {
console.info(`setHostname ${hostname}`);
this.serverConfig.hostnameForAccessKeys = hostname;
const requestOptions: RequestInit = {
method: 'PUT',
headers: new Headers({'Content-Type': 'application/json'}),
body: JSON.stringify({hostname})
};
return this.apiRequest<void>('server/hostname-for-access-keys', requestOptions).then(() => {
this.serverConfig.hostnameForAccessKeys = hostname;
});
}
getHostnameForAccessKeys(): string {
try {
return new URL(this.managementApiAddress).hostname;
return this.serverConfig.hostnameForAccessKeys || new URL(this.managementApiAddress).hostname;
} catch (e) {
return '';
}
}
getPortForNewAccessKeys(): number|undefined {
@ -149,7 +164,7 @@ export class ShadowboxServer implements server.Server {
}
setPortForNewAccessKeys(newPort: number): Promise<void> {
console.info(`setPortForNewAcessKeys: ${newPort}`);
console.info(`setPortForNewAccessKeys: ${newPort}`);
const requestOptions: RequestInit = {
method: 'PUT',
headers: new Headers({'Content-Type': 'application/json'}),

View file

@ -160,7 +160,7 @@ client <-> shadowbox <-> target
To test clients that rely on fetching a docker image from Dockerhub, you can push an image to your account and modify the
client to use your image. To push your own image:
```
yarn shadowbox_docker_build && docker tag quay.io/outline/shadowbox $USER/shadowbox && docker push $USER/shadowbox
yarn do shadowbox/docker/build && docker tag quay.io/outline/shadowbox $USER/shadowbox && docker push $USER/shadowbox
```
If you need to test an unsigned image (e.g. your dev one):

View file

@ -17,4 +17,4 @@
do_action shadowbox/server/build
export DOCKER_CONTENT_TRUST=${DOCKER_CONTENT_TRUST:-1}
docker build --force-rm --build-arg GITHUB_RELEASE="${TRAVIS_TAG:-none}" -t outline/shadowbox $ROOT_DIR -f src/shadowbox/docker/Dockerfile
docker build --force-rm --build-arg GITHUB_RELEASE="${TRAVIS_TAG:-none}" -t ${SB_IMAGE:-outline/shadowbox} $ROOT_DIR -f src/shadowbox/docker/Dockerfile

View file

@ -38,4 +38,4 @@ declare -a docker_bindings=(
echo "Running image ${SB_IMAGE}"
docker run --rm -it --network=host --name shadowbox "${docker_bindings[@]}" ${SB_IMAGE}
docker run --rm -it --network=host --name shadowbox "${docker_bindings[@]}" ${SB_IMAGE:-outline/shadowbox}

View file

@ -161,6 +161,18 @@ function cleanup() {
if [[ "${ACCESS_KEY_JSON}" != *'"port":12345'* ]]; then
fail "Port for new access keys wasn't changed. Newly created access key: ${ACCESS_KEY_JSON}"
fi
# Verify that we can change the hostname for new access keys
NEW_HOSTNAME="newhostname"
client_curl --insecure -X PUT -H 'Content-Type: application/json' -d '{"hostname": "'${NEW_HOSTNAME}'"}' ${SB_API_URL}/server/hostname-for-access-keys \
|| fail "Couldn't change hostname for new access keys"
ACCESS_KEY_JSON=$(client_curl --insecure -X POST ${SB_API_URL}/access-keys \
|| fail "Couldn't get a new access key after changing hostname")
if [[ "${ACCESS_KEY_JSON}" != *"@${NEW_HOSTNAME}:"* ]]; then
fail "Hostname for new access keys wasn't changed. Newly created access key: ${ACCESS_KEY_JSON}"
fi
# Verify no errors occurred.
readonly SHADOWBOX_LOG=$OUTPUT_DIR/shadowbox-log.txt

View file

@ -55,6 +55,8 @@ export interface AccessKeyRepository {
listAccessKeys(): AccessKey[];
// Changes the port for new access keys.
setPortForNewAccessKeys(port: number): Promise<void>;
// Changes the hostname for access keys.
setHostname(hostname: string): void;
// 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.

View file

@ -1,7 +1,7 @@
{
"name": "outline-server",
"private": true,
"version": "1.1.0",
"version": "1.2.0",
"description": "Outline server",
"main": "build/server/main.js",
"author": "Outline",
@ -11,6 +11,7 @@
],
"dependencies": {
"ShadowsocksConfig": "Jigsaw-Code/outline-shadowsocksconfig#^v0.0.9",
"ip-regex": "^4.1.0",
"js-yaml": "^3.12.0",
"prom-client": "^11.1.3",
"randomstring": "^1.1.5",

View file

@ -27,7 +27,34 @@ paths:
examples:
'0':
value: >-
{"name":"My Server","serverId":"000-000-000-000","metricsEnabled":true,"createdTimestampMs":1536613192052,"portForNewAccessKeys":1234}
{"name":"My Server","serverId":"000-000-000-000","metricsEnabled":true,"createdTimestampMs":1536613192052,"portForNewAccessKeys":1234, "version": "1.0.0"}
/server/hostname-for-access-keys:
put:
tags:
- Server
description: Changes the hostname for access keys. Must be a valid hostname or IP address. If it's a hostname, DNS must be set up independently of this API.
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
hostname:
type: string
examples:
'hostname':
value: '{"hostname": www.example.org}'
'IP address':
value: '{"hostname": "127.0.0.1"}'
responses:
'204':
description: The hostname was successfully changed.
'400':
description: An invalid hostname or IP address was provided.
'500':
description: An internal error occurred. This could be thrown if there were network errors while validating the hostname
/server/port-for-new-access-keys:
put:
description: Changes the default port for newly created access keys. This can be a port already used for access keys.
@ -47,7 +74,7 @@ paths:
value: '{"port": 12345}'
responses:
'204':
description: The default port was sucessfully changed.
description: The default port was successfully changed.
'400':
description: The requested port wasn't an integer from 1 through 65535, or the request had no port parameter.
'409':
@ -181,7 +208,7 @@ paths:
- Access Key
responses:
'200':
description: The data tranferred by each access key
description: The data transferred by each access key
content:
application/json:
schema:

View file

@ -14,7 +14,7 @@
import * as net from 'net';
import {InMemoryConfig} from '../infrastructure/json_config';
import {InMemoryConfig, JsonConfig} from '../infrastructure/json_config';
import {AccessKey, AccessKeyRepository, DataLimit} from '../model/access_key';
import {ShadowsocksManagerService} from './manager_service';
@ -22,6 +22,7 @@ import {FakePrometheusClient, FakeShadowsocksServer} from './mocks/mocks';
import {AccessKeyConfigJson, ServerAccessKeyRepository} from './server_access_key';
import {ServerConfigJson} from './server_config';
import {SharedMetricsPublisher} from './shared_metrics';
import { ManagerMetrics } from './manager_metrics';
interface ServerInfo {
name: string;
@ -49,7 +50,7 @@ describe('ShadowsocksManagerService', () => {
it('Return default name if name is absent', (done) => {
const repo = getAccessKeyRepository();
const serverConfig = new InMemoryConfig({} as ServerConfigJson);
const service = new ShadowsocksManagerService('default name', serverConfig, repo, null, null);
const service = new ShadowsocksManagerServiceBuilder().serverConfig(serverConfig).accessKeys(repo).build();
service.getServer(
{params: {}}, {
send: (httpCode, data: ServerInfo) => {
@ -63,9 +64,8 @@ describe('ShadowsocksManagerService', () => {
it('Returns persisted properties', (done) => {
const repo = getAccessKeyRepository();
const accessKeyDataLimit = {bytes: 999};
const serverConfig =
new InMemoryConfig({name: 'Server', accessKeyDataLimit} as ServerConfigJson);
const service = new ShadowsocksManagerService('default name', serverConfig, repo, null, null);
const serverConfig = new InMemoryConfig({name: 'Server', accessKeyDataLimit} as ServerConfigJson);
const service = new ShadowsocksManagerServiceBuilder().serverConfig(serverConfig).accessKeys(repo).build();
service.getServer(
{params: {}}, {
send: (httpCode, data: ServerInfo) => {
@ -83,7 +83,7 @@ describe('ShadowsocksManagerService', () => {
it('Rename changes the server name', (done) => {
const repo = getAccessKeyRepository();
const serverConfig = new InMemoryConfig({} as ServerConfigJson);
const service = new ShadowsocksManagerService('default name', serverConfig, repo, null, null);
const service = new ShadowsocksManagerServiceBuilder().serverConfig(serverConfig).accessKeys(repo).build();
service.renameServer(
{params: {name: 'new name'}}, {
send: (httpCode, _) => {
@ -96,10 +96,91 @@ describe('ShadowsocksManagerService', () => {
});
});
describe('setHostnameForAccessKeys', () => {
it(`accepts valid hostnames`, (done) => {
const serverConfig = new InMemoryConfig({} as ServerConfigJson);
const service = new ShadowsocksManagerServiceBuilder().serverConfig(serverConfig).accessKeys(getAccessKeyRepository()).build();
const res = {
send: (httpCode) => {
expect(httpCode).toEqual(204);
}
};
const goodHostnames = [
"-bad", "localhost","example.com","www.example.org","www.exa-mple.tw","123abc.co.uk","93.184.216.34","::0","2606:2800:220:1:248:1893:25c8:1946"
];
for (const hostname of goodHostnames) {
service.setHostnameForAccessKeys({params: {hostname}}, res, () => {});
}
responseProcessed = true;
done();
});
it(`rejects invalid hostnames`, (done) => {
const serverConfig = new InMemoryConfig({} as ServerConfigJson);
const service = new ShadowsocksManagerServiceBuilder().serverConfig(serverConfig).accessKeys(getAccessKeyRepository()).build();
const res = { send: (httpCode) => {} };
const next = (error) => {
expect(error.statusCode).toEqual(400);
};
const badHostnames = [
null, "","-abc.com","abc-.com","abc.com/def","i_have_underscores.net","gggg:ggg:220:1:248:1893:25c8:1946"
];
for (const hostname of badHostnames) {
service.setHostnameForAccessKeys({params: {hostname}}, res, next);
}
responseProcessed = true;
done();
});
it('Changes the server\'s hostname', (done) => {
const serverConfig = new InMemoryConfig({} as ServerConfigJson);
const service = new ShadowsocksManagerServiceBuilder().serverConfig(serverConfig).accessKeys(getAccessKeyRepository()).build();
const hostname = "www.example.org";
const res = {
send: (httpCode) => {
expect(httpCode).toEqual(204);
expect(serverConfig.data().hostname).toEqual(hostname);
responseProcessed = true;
}
};
service.setHostnameForAccessKeys({params: {hostname}}, res, done);
});
it('Rejects missing hostname', (done) => {
const serverConfig = new InMemoryConfig({} as ServerConfigJson);
const service = new ShadowsocksManagerServiceBuilder().serverConfig(serverConfig).accessKeys(getAccessKeyRepository()).build();
const res = { send: (httpCode) => {} };
const next = (error) => {
expect(error.statusCode).toEqual(400);
responseProcessed = true;
done();
};
const missingHostname = { params: {} } as {params: {hostname: string}};
service.setHostnameForAccessKeys(missingHostname, res, next);
});
it('Rejects non-string hostname', (done) => {
const serverConfig = new InMemoryConfig({} as ServerConfigJson);
const service = new ShadowsocksManagerServiceBuilder().serverConfig(serverConfig).accessKeys(getAccessKeyRepository()).build();
const res = { send: (httpCode) => {} };
const next = (error) => {
expect(error.statusCode).toEqual(400);
responseProcessed = true;
done();
};
// tslint:disable-next-line: no-any
const badHostname = ({ params: {hostname: 123} } as any ) as {params: {hostname: string}};
service.setHostnameForAccessKeys(badHostname, res, next);
});
});
describe('listAccessKeys', () => {
it('lists access keys in order', async (done) => {
const repo = getAccessKeyRepository();
const service = new ShadowsocksManagerService('default name', null, repo, null, null);
const service = new ShadowsocksManagerServiceBuilder().accessKeys(repo).build();
// Create 2 access keys with names.
const key1 = await createNewAccessKeyWithName(repo, 'keyName1');
const key2 = await createNewAccessKeyWithName(repo, 'keyName2');
@ -119,7 +200,7 @@ describe('ShadowsocksManagerService', () => {
});
it('lists access keys with expected properties', async (done) => {
const repo = getAccessKeyRepository();
const service = new ShadowsocksManagerService('', null, repo, null, null);
const service = new ShadowsocksManagerServiceBuilder().accessKeys(repo).build();
const accessKey = await repo.createNewAccessKey();
await repo.createNewAccessKey();
const accessKeyName = 'new name';
@ -143,7 +224,7 @@ describe('ShadowsocksManagerService', () => {
describe('createNewAccessKey', () => {
it('creates keys', (done) => {
const repo = getAccessKeyRepository();
const service = new ShadowsocksManagerService('default name', null, repo, null, null);
const service = new ShadowsocksManagerServiceBuilder().accessKeys(repo).build();
// Verify that response returns a key with the expected properties.
const res = {
@ -158,7 +239,7 @@ describe('ShadowsocksManagerService', () => {
it('Create returns a 500 when the repository throws an exception', (done) => {
const repo = getAccessKeyRepository();
spyOn(repo, 'createNewAccessKey').and.throwError('cannot write to disk');
const service = new ShadowsocksManagerService('default name', null, repo, null, null);
const service = new ShadowsocksManagerServiceBuilder().accessKeys(repo).build();
const res = {send: (httpCode, data) => {}};
service.createNewAccessKey({params: {}}, res, (error) => {
@ -172,7 +253,7 @@ describe('ShadowsocksManagerService', () => {
it('changes ports for new access keys', async (done) => {
const repo = getAccessKeyRepository();
const serverConfig = new InMemoryConfig({} as ServerConfigJson);
const service = new ShadowsocksManagerService('name', serverConfig, repo, null, null);
const service = new ShadowsocksManagerServiceBuilder().serverConfig(serverConfig).accessKeys(repo).build();
const oldKey = await repo.createNewAccessKey();
const res = {
@ -191,7 +272,7 @@ describe('ShadowsocksManagerService', () => {
it('changes the server config', async (done) => {
const repo = getAccessKeyRepository();
const serverConfig = new InMemoryConfig({} as ServerConfigJson);
const service = new ShadowsocksManagerService('name', serverConfig, repo, null, null);
const service = new ShadowsocksManagerServiceBuilder().serverConfig(serverConfig).accessKeys(repo).build();
const res = {
send: (httpCode) => {
@ -206,7 +287,7 @@ describe('ShadowsocksManagerService', () => {
it('rejects invalid port numbers', async (done) => {
const repo = getAccessKeyRepository();
const serverConfig = new InMemoryConfig({} as ServerConfigJson);
const service = new ShadowsocksManagerService('name', serverConfig, repo, null, null);
const service = new ShadowsocksManagerServiceBuilder().serverConfig(serverConfig).accessKeys(repo).build();
const res = {
send: (httpCode) => {
@ -232,7 +313,7 @@ describe('ShadowsocksManagerService', () => {
it('rejects port numbers already in use', async (done) => {
const repo = getAccessKeyRepository();
const serverConfig = new InMemoryConfig({} as ServerConfigJson);
const service = new ShadowsocksManagerService('name', serverConfig, repo, null, null);
const service = new ShadowsocksManagerServiceBuilder().serverConfig(serverConfig).accessKeys(repo).build();
const res = {
send: (httpCode) => {
@ -257,7 +338,7 @@ describe('ShadowsocksManagerService', () => {
it('accepts port numbers already in use by access keys', async (done) => {
const repo = getAccessKeyRepository();
const serverConfig = new InMemoryConfig({} as ServerConfigJson);
const service = new ShadowsocksManagerService('name', serverConfig, repo, null, null);
const service = new ShadowsocksManagerServiceBuilder().serverConfig(serverConfig).accessKeys(repo).build();
await service.createNewAccessKey({params: {}}, {send: () => {}}, () => {});
await service.setPortForNewAccessKeys({params: {port: NEW_PORT}}, {send: () => {}}, () => {});
@ -279,7 +360,7 @@ describe('ShadowsocksManagerService', () => {
it('rejects malformed requests', async (done) => {
const repo = getAccessKeyRepository();
const serverConfig = new InMemoryConfig({} as ServerConfigJson);
const service = new ShadowsocksManagerService('name', serverConfig, repo, null, null);
const service = new ShadowsocksManagerServiceBuilder().serverConfig(serverConfig).accessKeys(repo).build();
const noPort = {params: {}};
const res = {
@ -308,7 +389,7 @@ describe('ShadowsocksManagerService', () => {
describe('removeAccessKey', () => {
it('removes keys', async (done) => {
const repo = getAccessKeyRepository();
const service = new ShadowsocksManagerService('default name', null, repo, null, null);
const service = new ShadowsocksManagerServiceBuilder().accessKeys(repo).build();
const key1 = await repo.createNewAccessKey();
const key2 = await repo.createNewAccessKey();
const res = {
@ -327,7 +408,7 @@ describe('ShadowsocksManagerService', () => {
it('Remove returns a 500 when the repository throws an exception', async (done) => {
const repo = getAccessKeyRepository();
spyOn(repo, 'removeAccessKey').and.throwError('cannot write to disk');
const service = new ShadowsocksManagerService('default name', null, repo, null, null);
const service = new ShadowsocksManagerServiceBuilder().accessKeys(repo).build();
const key = await createNewAccessKeyWithName(repo, 'keyName1');
const res = {send: (httpCode, data) => {}};
service.removeAccessKey({params: {id: key.id}}, res, (error) => {
@ -341,7 +422,7 @@ describe('ShadowsocksManagerService', () => {
describe('renameAccessKey', () => {
it('renames keys', async (done) => {
const repo = getAccessKeyRepository();
const service = new ShadowsocksManagerService('default name', null, repo, null, null);
const service = new ShadowsocksManagerServiceBuilder().accessKeys(repo).build();
const OLD_NAME = 'oldName';
const NEW_NAME = 'newName';
@ -358,7 +439,7 @@ describe('ShadowsocksManagerService', () => {
});
it('Rename returns a 400 when the access key id is not a string', async (done) => {
const repo = getAccessKeyRepository();
const service = new ShadowsocksManagerService('default name', null, repo, null, null);
const service = new ShadowsocksManagerServiceBuilder().accessKeys(repo).build();
const key = await repo.createNewAccessKey();
const res = {send: (httpCode, data) => {}};
@ -371,7 +452,7 @@ describe('ShadowsocksManagerService', () => {
it('Rename returns a 500 when the repository throws an exception', async (done) => {
const repo = getAccessKeyRepository();
spyOn(repo, 'renameAccessKey').and.throwError('cannot write to disk');
const service = new ShadowsocksManagerService('default name', null, repo, null, null);
const service = new ShadowsocksManagerServiceBuilder().accessKeys(repo).build();
const key = await createNewAccessKeyWithName(repo, 'oldName');
const res = {send: (httpCode, data) => {}};
@ -388,7 +469,7 @@ describe('ShadowsocksManagerService', () => {
const serverConfig = new InMemoryConfig({} as ServerConfigJson);
const repo = getAccessKeyRepository();
spyOn(repo, 'setAccessKeyDataLimit');
const service = new ShadowsocksManagerService('default name', serverConfig, repo, null, null);
const service = new ShadowsocksManagerServiceBuilder().serverConfig(serverConfig).accessKeys(repo).build();
const limit = {bytes: 10000};
const res = {
send: (httpCode, data) => {
@ -410,7 +491,7 @@ describe('ShadowsocksManagerService', () => {
});
it('returns 400 when limit is missing values', async (done) => {
const repo = getAccessKeyRepository();
const service = new ShadowsocksManagerService('default name', null, repo, null, null);
const service = new ShadowsocksManagerServiceBuilder().accessKeys(repo).build();
const accessKey = await repo.createNewAccessKey();
const limit = {} as DataLimit;
const res = {send: (httpCode, data) => {}};
@ -422,7 +503,7 @@ describe('ShadowsocksManagerService', () => {
});
it('returns 400 when limit has negative values', async (done) => {
const repo = getAccessKeyRepository();
const service = new ShadowsocksManagerService('default name', null, repo, null, null);
const service = new ShadowsocksManagerServiceBuilder().accessKeys(repo).build();
const accessKey = await repo.createNewAccessKey();
const limit = {bytes: -1};
const res = {send: (httpCode, data) => {}};
@ -435,8 +516,8 @@ describe('ShadowsocksManagerService', () => {
it('returns 500 when the repository throws an exception', async (done) => {
const repo = getAccessKeyRepository();
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 service = new ShadowsocksManagerServiceBuilder().accessKeys(repo).build();
await repo.createNewAccessKey();
const limit = {bytes: 10000};
const res = {send: (httpCode, data) => {}};
service.setAccessKeyDataLimit({params: {limit}}, res, (error) => {
@ -453,7 +534,7 @@ describe('ShadowsocksManagerService', () => {
const serverConfig = new InMemoryConfig({'accessKeyDataLimit': limit} as ServerConfigJson);
const repo = getAccessKeyRepository();
spyOn(repo, 'removeAccessKeyDataLimit').and.callThrough();
const service = new ShadowsocksManagerService('default name', serverConfig, repo, null, null);
const service = new ShadowsocksManagerServiceBuilder().serverConfig(serverConfig).accessKeys(repo).build();
await repo.setAccessKeyDataLimit(limit);
const res = {
send: (httpCode, data) => {
@ -468,7 +549,7 @@ describe('ShadowsocksManagerService', () => {
it('returns 500 when the repository throws an exception', async (done) => {
const repo = getAccessKeyRepository();
spyOn(repo, 'removeAccessKeyDataLimit').and.throwError('cannot write to disk');
const service = new ShadowsocksManagerService('default name', null, repo, null, null);
const service = new ShadowsocksManagerServiceBuilder().accessKeys(repo).build();
const accessKey = await repo.createNewAccessKey();
const res = {send: (httpCode, data) => {}};
service.removeAccessKeyDataLimit({params: {id: accessKey.id}}, res, (error) => {
@ -483,8 +564,7 @@ describe('ShadowsocksManagerService', () => {
it('Returns value from sharedMetrics', (done) => {
const sharedMetrics = fakeSharedMetricsReporter();
sharedMetrics.startSharing();
const service =
new ShadowsocksManagerService('default name', null, null, null, sharedMetrics);
const service = new ShadowsocksManagerServiceBuilder().metricsPublisher(sharedMetrics).build();
service.getShareMetrics(
{params: {}}, {
send: (httpCode, data: {metricsEnabled: boolean}) => {
@ -500,8 +580,7 @@ describe('ShadowsocksManagerService', () => {
it('Sets value in the config', (done) => {
const sharedMetrics = fakeSharedMetricsReporter();
sharedMetrics.stopSharing();
const service =
new ShadowsocksManagerService('default name', null, null, null, sharedMetrics);
const service = new ShadowsocksManagerServiceBuilder().metricsPublisher(sharedMetrics).build();
service.setShareMetrics(
{params: {metricsEnabled: true}}, {
send: (httpCode, _) => {
@ -515,6 +594,43 @@ describe('ShadowsocksManagerService', () => {
});
});
class ShadowsocksManagerServiceBuilder {
private defaultServerName_ = 'default name';
private serverConfig_: JsonConfig<ServerConfigJson> = null;
private accessKeys_: AccessKeyRepository = null;
private managerMetrics_: ManagerMetrics = null;
private metricsPublisher_: SharedMetricsPublisher = null;
defaultServerName(name: string): ShadowsocksManagerServiceBuilder {
this.defaultServerName_ = name;
return this;
}
serverConfig(config: JsonConfig<ServerConfigJson>): ShadowsocksManagerServiceBuilder {
this.serverConfig_ = config;
return this;
}
accessKeys(keys: AccessKeyRepository): ShadowsocksManagerServiceBuilder {
this.accessKeys_ = keys;
return this;
}
managerMetrics(metrics: ManagerMetrics): ShadowsocksManagerServiceBuilder {
this.managerMetrics_ = metrics;
return this;
}
metricsPublisher(publisher: SharedMetricsPublisher): ShadowsocksManagerServiceBuilder {
this.metricsPublisher_ = publisher;
return this;
}
build(): ShadowsocksManagerService {
return new ShadowsocksManagerService(this.defaultServerName_, this.serverConfig_, this.accessKeys_, this.managerMetrics_, this.metricsPublisher_);
}
}
async function createNewAccessKeyWithName(
repo: AccessKeyRepository, name: string): Promise<AccessKey> {
const accessKey = await repo.createNewAccessKey();

View file

@ -13,6 +13,7 @@
// limitations under the License.
import * as restify from 'restify';
import * as ipRegex from 'ip-regex';
import {makeConfig, SIP002_URI} from 'ShadowsocksConfig/shadowsocks_config';
import {JsonConfig} from '../infrastructure/json_config';
@ -75,6 +76,7 @@ export function bindService(
apiServer: restify.Server, apiPrefix: string, service: ShadowsocksManagerService) {
apiServer.put(`${apiPrefix}/name`, service.renameServer.bind(service));
apiServer.get(`${apiPrefix}/server`, service.getServer.bind(service));
apiServer.put(`${apiPrefix}/server/hostname-for-access-keys`, service.setHostnameForAccessKeys.bind(service));
apiServer.put(
`${apiPrefix}/server/port-for-new-access-keys`,
service.setPortForNewAccessKeys.bind(service));
@ -142,12 +144,44 @@ export class ShadowsocksManagerService {
metricsEnabled: this.serverConfig.data().metricsEnabled || false,
createdTimestampMs: this.serverConfig.data().createdTimestampMs,
version,
accessKeyDataLimit: this.serverConfig.data().accessKeyDataLimit,
portForNewAccessKeys: this.serverConfig.data().portForNewAccessKeys,
accessKeyDataLimit: this.serverConfig.data().accessKeyDataLimit
hostnameForAccessKeys: this.serverConfig.data().hostname
});
next();
}
// Changes the server's hostname. Hostname must be a valid domain or IP address
public setHostnameForAccessKeys(req: RequestType, res: ResponseType, next: restify.Next): void {
logging.debug(`changeHostname request: ${JSON.stringify(req.params)}`);
const hostname = req.params.hostname;
if (typeof hostname === 'undefined') {
return next(new restify.MissingParameterError({statusCode: 400}, "hostname must be provided"));
}
if (typeof hostname !== 'string') {
return next(
new restify.InvalidArgumentError(
{statusCode: 400},
`Expected hostname to be a string, instead got ${hostname} of type ${typeof hostname}`));
}
// Hostnames can have any number of segments of alphanumeric characters and hyphens, separated by periods.
// No segment may start or end with a hyphen.
const hostnameRegex = /^([a-zA-Z0-9]([a-zA-Z0-9\-]*[a-zA-Z0-9])?\.)*[A-Za-z0-9]([A-Za-z0-9\-]*[A-Za-z0-9])?$/;
if (!hostnameRegex.test(hostname) && !ipRegex({includeBoundaries: true}).test(hostname)) {
return next(
new restify.InvalidArgumentError(
{statusCode: 400},
`Hostname ${hostname} isn't a valid hostname or IP address`));
}
this.serverConfig.data().hostname = hostname;
this.serverConfig.write();
this.accessKeys.setHostname(hostname);
res.send(HttpSuccess.NO_CONTENT);
next();
}
// Lists all access keys
public listAccessKeys(req: RequestType, res: ResponseType, next: restify.Next): void {
logging.debug(`listAccessKeys request ${JSON.stringify(req.params)}`);

View file

@ -385,6 +385,15 @@ describe('ServerAccessKeyRepository', () => {
expect(serverAccessKeys[1].id).toEqual(accessKey2.id);
done();
});
it('setHostname changes hostname for new keys', async (done) => {
const newHostname = "host2";
const repo = new RepoBuilder().build();
repo.setHostname(newHostname);
const key = await repo.createNewAccessKey();
expect(key.proxyParams.hostname).toEqual(newHostname);
done();
});
});
// Convenience function to expect that an asynchronous function does not throw an error. Note that

View file

@ -125,6 +125,10 @@ export class ServerAccessKeyRepository implements AccessKeyRepository {
});
}
setHostname(hostname: string): void {
this.proxyHostname = hostname;
}
async setPortForNewAccessKeys(port: number): Promise<void> {
if (!Number.isInteger(port) || port < 1 || port > 65535) {
throw new errors.InvalidPortNumber(port.toString());

View file

@ -3515,10 +3515,10 @@ invert-kv@^1.0.0:
resolved "https://registry.yarnpkg.com/invert-kv/-/invert-kv-1.0.0.tgz#104a8e4aaca6d3d8cd157a8ef8bfab2d7a3ffdb6"
integrity sha1-EEqOSqym09jNFXqO+L+rLXo//bY=
ip-regex@^1.0.1:
version "1.0.3"
resolved "https://registry.yarnpkg.com/ip-regex/-/ip-regex-1.0.3.tgz#dc589076f659f419c222039a33316f1c7387effd"
integrity sha1-3FiQdvZZ9BnCIgOaMzFvHHOH7/0=
ip-regex@^4.1.0:
version "4.1.0"
resolved "https://registry.yarnpkg.com/ip-regex/-/ip-regex-4.1.0.tgz#5ad62f685a14edb421abebc2fff8db94df67b455"
integrity sha512-pKnZpbgCTfH/1NLIlOduP/V+WRXzC2MOz3Qo8xmxk8C5GudJLgK5QyLVXOSWy3ParAH7Eemurl3xjv/WXYFvMA==
ipaddr.js@1.9.0:
version "1.9.0"