Skip to content

Commit

Permalink
feat(server): add an experimental opt-in API to enable ASN metrics (#…
Browse files Browse the repository at this point in the history
…1523)

* Update `update_mmdb.sh` script to download ASN database.

* Provide the `ASN` db file to `outline-ss-server`.

* Add an API to opt-in to the ASN metrics.

* Remove unused import.

* Add the ASN setting to the persisted server config.

* Continue trying to find other database if 1 of them fails.

* Resolve lint warning

* Use `outline-ss-server` v1.5.0.
  • Loading branch information
sbruens committed Mar 29, 2024
1 parent 37d3f33 commit 2901a25
Show file tree
Hide file tree
Showing 11 changed files with 244 additions and 36 deletions.
4 changes: 2 additions & 2 deletions src/shadowbox/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,9 +105,9 @@ The Outline Server provides a REST API for access key management. If you know th

- **Remove an access key:** `curl --insecure -X DELETE $API_URL/access-keys/1`

- **Set a data limit for all access keys:** (e.g. limit outbound data transfer access keys to 1MB over 30 days) `curl --insecure -X PUT -H "Content-Type: application/json" -d '{"limit": {"bytes": 1000}}' $API_URL/experimental/access-key-data-limit`
- **Set a data limit for all access keys:** (e.g. limit outbound data transfer access keys to 1MB over 30 days) `curl --insecure -X PUT -H "Content-Type: application/json" -d '{"limit": {"bytes": 1000}}' $API_URL/server/access-key-data-limit`

- **Remove the access key data limit:** `curl --insecure -X DELETE $API_URL/experimental/access-key-data-limit`
- **Remove the access key data limit:** `curl --insecure -X DELETE $API_URL/server/access-key-data-limit`

- **And more...**

Expand Down
3 changes: 3 additions & 0 deletions src/shadowbox/model/shadowsocks_server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ export interface ShadowsocksAccessKey {
}

export interface ShadowsocksServer {
// Annotates the Prometheus data metrics with ASN.
enableAsnMetrics(enable: boolean);

// Updates the server to accept only the given access keys.
update(keys: ShadowsocksAccessKey[]): Promise<void>;
}
72 changes: 55 additions & 17 deletions src/shadowbox/scripts/update_mmdb.sh
Original file line number Diff line number Diff line change
@@ -1,6 +1,20 @@
#!/bin/sh
#
# Copyright 2024 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.

# Download the IP-to-country MMDB database into the same location
# Download the IP-to-country and IP-to-ASN MMDB databases into the same location
# used by Alpine's libmaxminddb package.

# IP Geolocation by DB-IP (https://db-ip.com)
Expand All @@ -9,21 +23,45 @@

TMPDIR="$(mktemp -d)"
readonly TMPDIR
readonly FILENAME="ip-country.mmdb"

# We need to make sure that we grab an existing database at install-time
for monthdelta in $(seq 10); do
newdate="$(date --date="-${monthdelta} months" +%Y-%m)"
ADDRESS="https://download.db-ip.com/free/dbip-country-lite-${newdate}.mmdb.gz"
curl --fail --silent "${ADDRESS}" -o "${TMPDIR}/${FILENAME}.gz" > /dev/null && break
if [ "${monthdelta}" -eq '10' ]; then
# A weird exit code on purpose -- we should catch this long before it triggers
exit 2
readonly LIBDIR="/var/lib/libmaxminddb"

# Downloads a given MMDB database and writes it to the temporary directory.
# @param {string} The database to download.
download_ip_mmdb() {
db="$1"

for monthdelta in $(seq 0 9); do
newdate="$(date --date="-${monthdelta} months" +%Y-%m)"
address="https://download.db-ip.com/free/db${db}-lite-${newdate}.mmdb.gz"
curl --fail --silent "${address}" -o "${TMPDIR}/${db}.mmdb.gz" > /dev/null && return 0
done
return 1
}

main() {
status_code=0
# We need to make sure that we grab existing databases at install-time. If
# any fail, we continue to try to fetch other databases and will return a
# weird exit code at the end -- we should catch these failures long before
# they trigger.
if ! download_ip_mmdb "ip-country" ; then
echo "Failed to download IP-country database"
status_code=2
fi
if ! download_ip_mmdb "ip-asn" ; then
echo "Failed to download IP-ASN database"
status_code=2
fi
done

gunzip "${TMPDIR}/${FILENAME}.gz"
readonly LIBDIR="/var/lib/libmaxminddb"
mkdir -p "${LIBDIR}"
mv -f "${TMPDIR}/${FILENAME}" "${LIBDIR}"
rmdir "${TMPDIR}"
for filename in "${TMPDIR}"/*; do
gunzip "${filename}"
done

mkdir -p "${LIBDIR}"
mv -f "${TMPDIR}"/* "${LIBDIR}"
rmdir "${TMPDIR}"

exit "${status_code}"
}

main "$@"
27 changes: 27 additions & 0 deletions src/shadowbox/server/api.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ tags:
description: Server-level functions
- name: Access Key
description: Access key functions
- name: Experimental
description: Experimental functions. These are unstable and may disappear. Use with care.
servers:
- url: https://myserver/SecretPath
description: Example URL. Change to your own server.
Expand Down Expand Up @@ -434,13 +436,37 @@ paths:
description: Setting successful
'400':
description: Invalid request
/experimental/asn-metrics/enabled:
put:
description: Annotates Prometheus data metrics with autonomous system numbers (ASN).
tags:
- Server
- Experimental
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
asnMetricsEnabled:
type: boolean
examples:
'0':
value: '{"asnMetricsEnabled": true}'
responses:
'204':
description: Setting successful
'400':
description: Invalid request
/experimental/access-key-data-limit:
put:
deprecated: true
description: (Deprecated) Sets a data transfer limit for all access keys
tags:
- Access Key
- Limit
- Experimental
requestBody:
required: true
content:
Expand All @@ -461,6 +487,7 @@ paths:
tags:
- Access Key
- Limit
- Experimental
responses:
'204':
description: Access key limit deleted successfully.
Expand Down
14 changes: 11 additions & 3 deletions src/shadowbox/server/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,8 @@ import {

const APP_BASE_DIR = path.join(__dirname, '..');
const DEFAULT_STATE_DIR = '/root/shadowbox/persisted-state';
const MMDB_LOCATION = '/var/lib/libmaxminddb/ip-country.mmdb';
const MMDB_LOCATION_COUNTRY = '/var/lib/libmaxminddb/ip-country.mmdb';
const MMDB_LOCATION_ASN = '/var/lib/libmaxminddb/ip-asn.mmdb';

async function exportPrometheusMetrics(registry: prometheus.Registry, port): Promise<http.Server> {
return new Promise<http.Server>((resolve, _) => {
Expand Down Expand Up @@ -155,8 +156,14 @@ async function main() {
verbose,
ssMetricsLocation
);
if (fs.existsSync(MMDB_LOCATION)) {
shadowsocksServer.enableCountryMetrics(MMDB_LOCATION);
if (fs.existsSync(MMDB_LOCATION_COUNTRY)) {
shadowsocksServer.configureCountryMetrics(MMDB_LOCATION_COUNTRY);
}
if (fs.existsSync(MMDB_LOCATION_ASN)) {
shadowsocksServer.configureAsnMetrics(MMDB_LOCATION_ASN);
if (serverConfig.data().experimental?.asnMetricsEnabled) {
shadowsocksServer.enableAsnMetrics(true);
}
}

const isReplayProtectionEnabled = createRolloutTracker(serverConfig).isRolloutEnabled(
Expand Down Expand Up @@ -230,6 +237,7 @@ async function main() {
process.env.SB_DEFAULT_SERVER_NAME || 'Outline Server',
serverConfig,
accessKeyRepository,
shadowsocksServer,
managerMetrics,
metricsPublisher
);
Expand Down
49 changes: 49 additions & 0 deletions src/shadowbox/server/manager_service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,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 {ShadowsocksServer} from '../model/shadowsocks_server';

interface ServerInfo {
name: string;
Expand Down Expand Up @@ -1067,6 +1068,47 @@ describe('ShadowsocksManagerService', () => {
);
});
});
describe('enableAsnMetrics', () => {
it('Enables ASN metrics on the Shadowsocks Server', (done) => {
const serverConfig = new InMemoryConfig({} as ServerConfigJson);
const shadowsocksServer = new FakeShadowsocksServer();
spyOn(shadowsocksServer, 'enableAsnMetrics');
const service = new ShadowsocksManagerServiceBuilder()
.serverConfig(serverConfig)
.shadowsocksServer(shadowsocksServer)
.build();
service.enableAsnMetrics(
{params: {asnMetricsEnabled: true}},
{
send: (httpCode, _) => {
expect(httpCode).toEqual(204);
expect(shadowsocksServer.enableAsnMetrics).toHaveBeenCalledWith(true);
responseProcessed = true;
},
},
done
);
});
it('Sets value in the config', (done) => {
const serverConfig = new InMemoryConfig({} as ServerConfigJson);
const shadowsocksServer = new FakeShadowsocksServer();
const service = new ShadowsocksManagerServiceBuilder()
.serverConfig(serverConfig)
.shadowsocksServer(shadowsocksServer)
.build();
service.enableAsnMetrics(
{params: {asnMetricsEnabled: true}},
{
send: (httpCode, _) => {
expect(httpCode).toEqual(204);
expect(serverConfig.mostRecentWrite.experimental.asnMetricsEnabled).toBeTrue();
responseProcessed = true;
},
},
done
);
});
});
});

describe('bindService', () => {
Expand Down Expand Up @@ -1194,6 +1236,7 @@ class ShadowsocksManagerServiceBuilder {
private defaultServerName_ = 'default name';
private serverConfig_: JsonConfig<ServerConfigJson> = null;
private accessKeys_: AccessKeyRepository = null;
private shadowsocksServer_: ShadowsocksServer = null;
private managerMetrics_: ManagerMetrics = null;
private metricsPublisher_: SharedMetricsPublisher = null;

Expand All @@ -1212,6 +1255,11 @@ class ShadowsocksManagerServiceBuilder {
return this;
}

shadowsocksServer(server: ShadowsocksServer) {
this.shadowsocksServer_ = server;
return this;
}

managerMetrics(metrics: ManagerMetrics): ShadowsocksManagerServiceBuilder {
this.managerMetrics_ = metrics;
return this;
Expand All @@ -1227,6 +1275,7 @@ class ShadowsocksManagerServiceBuilder {
this.defaultServerName_,
this.serverConfig_,
this.accessKeys_,
this.shadowsocksServer_,
this.managerMetrics_,
this.metricsPublisher_
);
Expand Down
45 changes: 44 additions & 1 deletion src/shadowbox/server/manager_service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import * as version from './version';
import {ManagerMetrics} from './manager_metrics';
import {ServerConfigJson} from './server_config';
import {SharedMetricsPublisher} from './shared_metrics';
import {ShadowsocksServer} from '../model/shadowsocks_server';

interface AccessKeyJson {
// The unique identifier of this access key.
Expand Down Expand Up @@ -158,6 +159,13 @@ export function bindService(
apiServer.get(`${apiPrefix}/metrics/enabled`, service.getShareMetrics.bind(service));
apiServer.put(`${apiPrefix}/metrics/enabled`, service.setShareMetrics.bind(service));

// Experimental APIs.

apiServer.put(
`${apiPrefix}/experimental/asn-metrics/enabled`,
service.enableAsnMetrics.bind(service)
);

// Redirect former experimental APIs
apiServer.put(
`${apiPrefix}/experimental/access-key-data-limit`,
Expand Down Expand Up @@ -240,6 +248,7 @@ export class ShadowsocksManagerService {
private defaultServerName: string,
private serverConfig: JsonConfig<ServerConfigJson>,
private accessKeys: AccessKeyRepository,
private shadowsocksServer: ShadowsocksServer,
private managerMetrics: ManagerMetrics,
private metricsPublisher: SharedMetricsPublisher
) {}
Expand Down Expand Up @@ -276,6 +285,7 @@ export class ShadowsocksManagerService {
accessKeyDataLimit: this.serverConfig.data().accessKeyDataLimit,
portForNewAccessKeys: this.serverConfig.data().portForNewAccessKeys,
hostnameForAccessKeys: this.serverConfig.data().hostname,
experimental: this.serverConfig.data().experimental,
});
next();
}
Expand Down Expand Up @@ -621,7 +631,7 @@ export class ShadowsocksManagerService {
return next(
new restifyErrors.InvalidArgumentError(
{statusCode: 400},
'Parameter `hours` must be an integer'
'Parameter `metricsEnabled` must be a boolean'
)
);
}
Expand All @@ -633,4 +643,37 @@ export class ShadowsocksManagerService {
res.send(HttpSuccess.NO_CONTENT);
next();
}

public enableAsnMetrics(req: RequestType, res: ResponseType, next: restify.Next): void {
try {
logging.debug(`enableAsnMetrics request ${JSON.stringify(req.params)}`);
const asnMetricsEnabled = req.params.asnMetricsEnabled;
if (asnMetricsEnabled === undefined || asnMetricsEnabled === null) {
return next(
new restifyErrors.MissingParameterError(
{statusCode: 400},
'Parameter `asnMetricsEnabled` is missing'
)
);
} else if (typeof asnMetricsEnabled !== 'boolean') {
return next(
new restifyErrors.InvalidArgumentError(
{statusCode: 400},
'Parameter `asnMetricsEnabled` must be a boolean'
)
);
}
this.shadowsocksServer.enableAsnMetrics(asnMetricsEnabled);
if (this.serverConfig.data().experimental === undefined) {
this.serverConfig.data().experimental = {};
}
this.serverConfig.data().experimental.asnMetricsEnabled = asnMetricsEnabled;
this.serverConfig.write();
res.send(HttpSuccess.NO_CONTENT);
return next();
} catch (error) {
logging.error(error);
return next(new restifyErrors.InternalServerError());
}
}
}
2 changes: 2 additions & 0 deletions src/shadowbox/server/mocks/mocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ export class InMemoryFile implements TextFile {
export class FakeShadowsocksServer implements ShadowsocksServer {
private accessKeys: ShadowsocksAccessKey[] = [];

enableAsnMetrics(_: boolean) {}

update(keys: ShadowsocksAccessKey[]) {
this.accessKeys = keys;
return Promise.resolve();
Expand Down
Loading

0 comments on commit 2901a25

Please sign in to comment.