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): add an experimental opt-in API to enable ASN metrics #1523

Merged
merged 8 commits into from
Mar 29, 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
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
Loading