Skip to content

Commit 2901a25

Browse files
authored
feat(server): add an experimental opt-in API to enable ASN metrics (#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.
1 parent 37d3f33 commit 2901a25

File tree

11 files changed

+244
-36
lines changed

11 files changed

+244
-36
lines changed

src/shadowbox/README.md

+2-2
Original file line numberDiff line numberDiff line change
@@ -105,9 +105,9 @@ The Outline Server provides a REST API for access key management. If you know th
105105

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

108-
- **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`
108+
- **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`
109109

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

112112
- **And more...**
113113

src/shadowbox/model/shadowsocks_server.ts

+3
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ export interface ShadowsocksAccessKey {
2121
}
2222

2323
export interface ShadowsocksServer {
24+
// Annotates the Prometheus data metrics with ASN.
25+
enableAsnMetrics(enable: boolean);
26+
2427
// Updates the server to accept only the given access keys.
2528
update(keys: ShadowsocksAccessKey[]): Promise<void>;
2629
}

src/shadowbox/scripts/update_mmdb.sh

+55-17
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,20 @@
11
#!/bin/sh
2+
#
3+
# Copyright 2024 The Outline Authors
4+
#
5+
# Licensed under the Apache License, Version 2.0 (the "License");
6+
# you may not use this file except in compliance with the License.
7+
# You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing, software
12+
# distributed under the License is distributed on an "AS IS" BASIS,
13+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
# See the License for the specific language governing permissions and
15+
# limitations under the License.
216

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

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

1024
TMPDIR="$(mktemp -d)"
1125
readonly TMPDIR
12-
readonly FILENAME="ip-country.mmdb"
13-
14-
# We need to make sure that we grab an existing database at install-time
15-
for monthdelta in $(seq 10); do
16-
newdate="$(date --date="-${monthdelta} months" +%Y-%m)"
17-
ADDRESS="https://download.db-ip.com/free/dbip-country-lite-${newdate}.mmdb.gz"
18-
curl --fail --silent "${ADDRESS}" -o "${TMPDIR}/${FILENAME}.gz" > /dev/null && break
19-
if [ "${monthdelta}" -eq '10' ]; then
20-
# A weird exit code on purpose -- we should catch this long before it triggers
21-
exit 2
26+
readonly LIBDIR="/var/lib/libmaxminddb"
27+
28+
# Downloads a given MMDB database and writes it to the temporary directory.
29+
# @param {string} The database to download.
30+
download_ip_mmdb() {
31+
db="$1"
32+
33+
for monthdelta in $(seq 0 9); do
34+
newdate="$(date --date="-${monthdelta} months" +%Y-%m)"
35+
address="https://download.db-ip.com/free/db${db}-lite-${newdate}.mmdb.gz"
36+
curl --fail --silent "${address}" -o "${TMPDIR}/${db}.mmdb.gz" > /dev/null && return 0
37+
done
38+
return 1
39+
}
40+
41+
main() {
42+
status_code=0
43+
# We need to make sure that we grab existing databases at install-time. If
44+
# any fail, we continue to try to fetch other databases and will return a
45+
# weird exit code at the end -- we should catch these failures long before
46+
# they trigger.
47+
if ! download_ip_mmdb "ip-country" ; then
48+
echo "Failed to download IP-country database"
49+
status_code=2
50+
fi
51+
if ! download_ip_mmdb "ip-asn" ; then
52+
echo "Failed to download IP-ASN database"
53+
status_code=2
2254
fi
23-
done
2455

25-
gunzip "${TMPDIR}/${FILENAME}.gz"
26-
readonly LIBDIR="/var/lib/libmaxminddb"
27-
mkdir -p "${LIBDIR}"
28-
mv -f "${TMPDIR}/${FILENAME}" "${LIBDIR}"
29-
rmdir "${TMPDIR}"
56+
for filename in "${TMPDIR}"/*; do
57+
gunzip "${filename}"
58+
done
59+
60+
mkdir -p "${LIBDIR}"
61+
mv -f "${TMPDIR}"/* "${LIBDIR}"
62+
rmdir "${TMPDIR}"
63+
64+
exit "${status_code}"
65+
}
66+
67+
main "$@"

src/shadowbox/server/api.yml

+27
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ tags:
88
description: Server-level functions
99
- name: Access Key
1010
description: Access key functions
11+
- name: Experimental
12+
description: Experimental functions. These are unstable and may disappear. Use with care.
1113
servers:
1214
- url: https://myserver/SecretPath
1315
description: Example URL. Change to your own server.
@@ -434,13 +436,37 @@ paths:
434436
description: Setting successful
435437
'400':
436438
description: Invalid request
439+
/experimental/asn-metrics/enabled:
440+
put:
441+
description: Annotates Prometheus data metrics with autonomous system numbers (ASN).
442+
tags:
443+
- Server
444+
- Experimental
445+
requestBody:
446+
required: true
447+
content:
448+
application/json:
449+
schema:
450+
type: object
451+
properties:
452+
asnMetricsEnabled:
453+
type: boolean
454+
examples:
455+
'0':
456+
value: '{"asnMetricsEnabled": true}'
457+
responses:
458+
'204':
459+
description: Setting successful
460+
'400':
461+
description: Invalid request
437462
/experimental/access-key-data-limit:
438463
put:
439464
deprecated: true
440465
description: (Deprecated) Sets a data transfer limit for all access keys
441466
tags:
442467
- Access Key
443468
- Limit
469+
- Experimental
444470
requestBody:
445471
required: true
446472
content:
@@ -461,6 +487,7 @@ paths:
461487
tags:
462488
- Access Key
463489
- Limit
490+
- Experimental
464491
responses:
465492
'204':
466493
description: Access key limit deleted successfully.

src/shadowbox/server/main.ts

+11-3
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,8 @@ import {
4343

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

4849
async function exportPrometheusMetrics(registry: prometheus.Registry, port): Promise<http.Server> {
4950
return new Promise<http.Server>((resolve, _) => {
@@ -155,8 +156,14 @@ async function main() {
155156
verbose,
156157
ssMetricsLocation
157158
);
158-
if (fs.existsSync(MMDB_LOCATION)) {
159-
shadowsocksServer.enableCountryMetrics(MMDB_LOCATION);
159+
if (fs.existsSync(MMDB_LOCATION_COUNTRY)) {
160+
shadowsocksServer.configureCountryMetrics(MMDB_LOCATION_COUNTRY);
161+
}
162+
if (fs.existsSync(MMDB_LOCATION_ASN)) {
163+
shadowsocksServer.configureAsnMetrics(MMDB_LOCATION_ASN);
164+
if (serverConfig.data().experimental?.asnMetricsEnabled) {
165+
shadowsocksServer.enableAsnMetrics(true);
166+
}
160167
}
161168

162169
const isReplayProtectionEnabled = createRolloutTracker(serverConfig).isRolloutEnabled(
@@ -230,6 +237,7 @@ async function main() {
230237
process.env.SB_DEFAULT_SERVER_NAME || 'Outline Server',
231238
serverConfig,
232239
accessKeyRepository,
240+
shadowsocksServer,
233241
managerMetrics,
234242
metricsPublisher
235243
);

src/shadowbox/server/manager_service.spec.ts

+49
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {FakePrometheusClient, FakeShadowsocksServer} from './mocks/mocks';
2424
import {AccessKeyConfigJson, ServerAccessKeyRepository} from './server_access_key';
2525
import {ServerConfigJson} from './server_config';
2626
import {SharedMetricsPublisher} from './shared_metrics';
27+
import {ShadowsocksServer} from '../model/shadowsocks_server';
2728

2829
interface ServerInfo {
2930
name: string;
@@ -1067,6 +1068,47 @@ describe('ShadowsocksManagerService', () => {
10671068
);
10681069
});
10691070
});
1071+
describe('enableAsnMetrics', () => {
1072+
it('Enables ASN metrics on the Shadowsocks Server', (done) => {
1073+
const serverConfig = new InMemoryConfig({} as ServerConfigJson);
1074+
const shadowsocksServer = new FakeShadowsocksServer();
1075+
spyOn(shadowsocksServer, 'enableAsnMetrics');
1076+
const service = new ShadowsocksManagerServiceBuilder()
1077+
.serverConfig(serverConfig)
1078+
.shadowsocksServer(shadowsocksServer)
1079+
.build();
1080+
service.enableAsnMetrics(
1081+
{params: {asnMetricsEnabled: true}},
1082+
{
1083+
send: (httpCode, _) => {
1084+
expect(httpCode).toEqual(204);
1085+
expect(shadowsocksServer.enableAsnMetrics).toHaveBeenCalledWith(true);
1086+
responseProcessed = true;
1087+
},
1088+
},
1089+
done
1090+
);
1091+
});
1092+
it('Sets value in the config', (done) => {
1093+
const serverConfig = new InMemoryConfig({} as ServerConfigJson);
1094+
const shadowsocksServer = new FakeShadowsocksServer();
1095+
const service = new ShadowsocksManagerServiceBuilder()
1096+
.serverConfig(serverConfig)
1097+
.shadowsocksServer(shadowsocksServer)
1098+
.build();
1099+
service.enableAsnMetrics(
1100+
{params: {asnMetricsEnabled: true}},
1101+
{
1102+
send: (httpCode, _) => {
1103+
expect(httpCode).toEqual(204);
1104+
expect(serverConfig.mostRecentWrite.experimental.asnMetricsEnabled).toBeTrue();
1105+
responseProcessed = true;
1106+
},
1107+
},
1108+
done
1109+
);
1110+
});
1111+
});
10701112
});
10711113

10721114
describe('bindService', () => {
@@ -1194,6 +1236,7 @@ class ShadowsocksManagerServiceBuilder {
11941236
private defaultServerName_ = 'default name';
11951237
private serverConfig_: JsonConfig<ServerConfigJson> = null;
11961238
private accessKeys_: AccessKeyRepository = null;
1239+
private shadowsocksServer_: ShadowsocksServer = null;
11971240
private managerMetrics_: ManagerMetrics = null;
11981241
private metricsPublisher_: SharedMetricsPublisher = null;
11991242

@@ -1212,6 +1255,11 @@ class ShadowsocksManagerServiceBuilder {
12121255
return this;
12131256
}
12141257

1258+
shadowsocksServer(server: ShadowsocksServer) {
1259+
this.shadowsocksServer_ = server;
1260+
return this;
1261+
}
1262+
12151263
managerMetrics(metrics: ManagerMetrics): ShadowsocksManagerServiceBuilder {
12161264
this.managerMetrics_ = metrics;
12171265
return this;
@@ -1227,6 +1275,7 @@ class ShadowsocksManagerServiceBuilder {
12271275
this.defaultServerName_,
12281276
this.serverConfig_,
12291277
this.accessKeys_,
1278+
this.shadowsocksServer_,
12301279
this.managerMetrics_,
12311280
this.metricsPublisher_
12321281
);

src/shadowbox/server/manager_service.ts

+44-1
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import * as version from './version';
2727
import {ManagerMetrics} from './manager_metrics';
2828
import {ServerConfigJson} from './server_config';
2929
import {SharedMetricsPublisher} from './shared_metrics';
30+
import {ShadowsocksServer} from '../model/shadowsocks_server';
3031

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

162+
// Experimental APIs.
163+
164+
apiServer.put(
165+
`${apiPrefix}/experimental/asn-metrics/enabled`,
166+
service.enableAsnMetrics.bind(service)
167+
);
168+
161169
// Redirect former experimental APIs
162170
apiServer.put(
163171
`${apiPrefix}/experimental/access-key-data-limit`,
@@ -240,6 +248,7 @@ export class ShadowsocksManagerService {
240248
private defaultServerName: string,
241249
private serverConfig: JsonConfig<ServerConfigJson>,
242250
private accessKeys: AccessKeyRepository,
251+
private shadowsocksServer: ShadowsocksServer,
243252
private managerMetrics: ManagerMetrics,
244253
private metricsPublisher: SharedMetricsPublisher
245254
) {}
@@ -276,6 +285,7 @@ export class ShadowsocksManagerService {
276285
accessKeyDataLimit: this.serverConfig.data().accessKeyDataLimit,
277286
portForNewAccessKeys: this.serverConfig.data().portForNewAccessKeys,
278287
hostnameForAccessKeys: this.serverConfig.data().hostname,
288+
experimental: this.serverConfig.data().experimental,
279289
});
280290
next();
281291
}
@@ -621,7 +631,7 @@ export class ShadowsocksManagerService {
621631
return next(
622632
new restifyErrors.InvalidArgumentError(
623633
{statusCode: 400},
624-
'Parameter `hours` must be an integer'
634+
'Parameter `metricsEnabled` must be a boolean'
625635
)
626636
);
627637
}
@@ -633,4 +643,37 @@ export class ShadowsocksManagerService {
633643
res.send(HttpSuccess.NO_CONTENT);
634644
next();
635645
}
646+
647+
public enableAsnMetrics(req: RequestType, res: ResponseType, next: restify.Next): void {
648+
try {
649+
logging.debug(`enableAsnMetrics request ${JSON.stringify(req.params)}`);
650+
const asnMetricsEnabled = req.params.asnMetricsEnabled;
651+
if (asnMetricsEnabled === undefined || asnMetricsEnabled === null) {
652+
return next(
653+
new restifyErrors.MissingParameterError(
654+
{statusCode: 400},
655+
'Parameter `asnMetricsEnabled` is missing'
656+
)
657+
);
658+
} else if (typeof asnMetricsEnabled !== 'boolean') {
659+
return next(
660+
new restifyErrors.InvalidArgumentError(
661+
{statusCode: 400},
662+
'Parameter `asnMetricsEnabled` must be a boolean'
663+
)
664+
);
665+
}
666+
this.shadowsocksServer.enableAsnMetrics(asnMetricsEnabled);
667+
if (this.serverConfig.data().experimental === undefined) {
668+
this.serverConfig.data().experimental = {};
669+
}
670+
this.serverConfig.data().experimental.asnMetricsEnabled = asnMetricsEnabled;
671+
this.serverConfig.write();
672+
res.send(HttpSuccess.NO_CONTENT);
673+
return next();
674+
} catch (error) {
675+
logging.error(error);
676+
return next(new restifyErrors.InternalServerError());
677+
}
678+
}
636679
}

src/shadowbox/server/mocks/mocks.ts

+2
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ export class InMemoryFile implements TextFile {
3838
export class FakeShadowsocksServer implements ShadowsocksServer {
3939
private accessKeys: ShadowsocksAccessKey[] = [];
4040

41+
enableAsnMetrics(_: boolean) {}
42+
4143
update(keys: ShadowsocksAccessKey[]) {
4244
this.accessKeys = keys;
4345
return Promise.resolve();

0 commit comments

Comments
 (0)