diff --git a/src/shadowbox/README.md b/src/shadowbox/README.md index b04bb4d7f..74c831fb7 100644 --- a/src/shadowbox/README.md +++ b/src/shadowbox/README.md @@ -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...** diff --git a/src/shadowbox/model/shadowsocks_server.ts b/src/shadowbox/model/shadowsocks_server.ts index a3416ef95..a7ea3745e 100644 --- a/src/shadowbox/model/shadowsocks_server.ts +++ b/src/shadowbox/model/shadowsocks_server.ts @@ -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; } diff --git a/src/shadowbox/scripts/update_mmdb.sh b/src/shadowbox/scripts/update_mmdb.sh index b283321cb..463280b29 100755 --- a/src/shadowbox/scripts/update_mmdb.sh +++ b/src/shadowbox/scripts/update_mmdb.sh @@ -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) @@ -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 "$@" diff --git a/src/shadowbox/server/api.yml b/src/shadowbox/server/api.yml index 13cb55607..c253fa4c4 100644 --- a/src/shadowbox/server/api.yml +++ b/src/shadowbox/server/api.yml @@ -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. @@ -434,6 +436,29 @@ 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 @@ -441,6 +466,7 @@ paths: tags: - Access Key - Limit + - Experimental requestBody: required: true content: @@ -461,6 +487,7 @@ paths: tags: - Access Key - Limit + - Experimental responses: '204': description: Access key limit deleted successfully. diff --git a/src/shadowbox/server/main.ts b/src/shadowbox/server/main.ts index ba938eb7a..fe8dbcb53 100644 --- a/src/shadowbox/server/main.ts +++ b/src/shadowbox/server/main.ts @@ -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 { return new Promise((resolve, _) => { @@ -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( @@ -230,6 +237,7 @@ async function main() { process.env.SB_DEFAULT_SERVER_NAME || 'Outline Server', serverConfig, accessKeyRepository, + shadowsocksServer, managerMetrics, metricsPublisher ); diff --git a/src/shadowbox/server/manager_service.spec.ts b/src/shadowbox/server/manager_service.spec.ts index 41933030f..52f73a9cf 100644 --- a/src/shadowbox/server/manager_service.spec.ts +++ b/src/shadowbox/server/manager_service.spec.ts @@ -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; @@ -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', () => { @@ -1194,6 +1236,7 @@ class ShadowsocksManagerServiceBuilder { private defaultServerName_ = 'default name'; private serverConfig_: JsonConfig = null; private accessKeys_: AccessKeyRepository = null; + private shadowsocksServer_: ShadowsocksServer = null; private managerMetrics_: ManagerMetrics = null; private metricsPublisher_: SharedMetricsPublisher = null; @@ -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; @@ -1227,6 +1275,7 @@ class ShadowsocksManagerServiceBuilder { this.defaultServerName_, this.serverConfig_, this.accessKeys_, + this.shadowsocksServer_, this.managerMetrics_, this.metricsPublisher_ ); diff --git a/src/shadowbox/server/manager_service.ts b/src/shadowbox/server/manager_service.ts index 1299b9b55..12721febf 100644 --- a/src/shadowbox/server/manager_service.ts +++ b/src/shadowbox/server/manager_service.ts @@ -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. @@ -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`, @@ -240,6 +248,7 @@ export class ShadowsocksManagerService { private defaultServerName: string, private serverConfig: JsonConfig, private accessKeys: AccessKeyRepository, + private shadowsocksServer: ShadowsocksServer, private managerMetrics: ManagerMetrics, private metricsPublisher: SharedMetricsPublisher ) {} @@ -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(); } @@ -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' ) ); } @@ -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()); + } + } } diff --git a/src/shadowbox/server/mocks/mocks.ts b/src/shadowbox/server/mocks/mocks.ts index 248cd55b0..2b133aa98 100644 --- a/src/shadowbox/server/mocks/mocks.ts +++ b/src/shadowbox/server/mocks/mocks.ts @@ -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(); diff --git a/src/shadowbox/server/outline_shadowsocks_server.ts b/src/shadowbox/server/outline_shadowsocks_server.ts index da27c874f..8ea083f29 100644 --- a/src/shadowbox/server/outline_shadowsocks_server.ts +++ b/src/shadowbox/server/outline_shadowsocks_server.ts @@ -24,11 +24,17 @@ import {ShadowsocksAccessKey, ShadowsocksServer} from '../model/shadowsocks_serv // Runs outline-ss-server. export class OutlineShadowsocksServer implements ShadowsocksServer { private ssProcess: child_process.ChildProcess; - private ipCountryFilename = ''; + private ipCountryFilename?: string; + private ipAsnFilename?: string; + private isAsnMetricsEnabled = false; private isReplayProtectionEnabled = false; - // binaryFilename is the location for the outline-ss-server binary. - // configFilename is the location for the outline-ss-server config. + /** + * @param binaryFilename The location for the outline-ss-server binary. + * @param configFilename The location for the outline-ss-server config. + * @param verbose Whether to run the server in verbose mode. + * @param metricsLocation The location from where to serve the Prometheus data metrics. + */ constructor( private readonly binaryFilename: string, private readonly configFilename: string, @@ -36,13 +42,36 @@ export class OutlineShadowsocksServer implements ShadowsocksServer { private readonly metricsLocation: string ) {} - // Annotates the Prometheus data metrics with countries. - // ipCountryFilename is the location of the ip-country.mmdb IP-to-country database file. - enableCountryMetrics(ipCountryFilename: string): OutlineShadowsocksServer { + /** + * Configures the Shadowsocks Server with country data to annotate Prometheus data metrics. + * @param ipCountryFilename The location of the ip-country.mmdb IP-to-country database file. + */ + configureCountryMetrics(ipCountryFilename: string): OutlineShadowsocksServer { this.ipCountryFilename = ipCountryFilename; return this; } + /** + * Configures the Shadowsocks Server with ASN data to annotate Prometheus data metrics. + * @param ipAsnFilename The location of the ip-asn.mmdb IP-to-ASN database file. + */ + configureAsnMetrics(ipAsnFilename: string): OutlineShadowsocksServer { + this.ipAsnFilename = ipAsnFilename; + return this; + } + + /** Annotates the Prometheus data metrics with ASN. */ + enableAsnMetrics(enable: boolean) { + if (enable && !this.ipAsnFilename) { + throw new Error('Cannot enable ASN metrics: no ASN database filename set'); + } + const valueChanged = this.isAsnMetricsEnabled != enable; + this.isAsnMetricsEnabled = enable; + if (valueChanged && this.ssProcess) { + this.ssProcess.kill('SIGTERM'); + } + } + enableReplayProtection(): OutlineShadowsocksServer { this.isReplayProtectionEnabled = true; return this; @@ -92,6 +121,9 @@ export class OutlineShadowsocksServer implements ShadowsocksServer { if (this.ipCountryFilename) { commandArguments.push('-ip_country_db', this.ipCountryFilename); } + if (this.isAsnMetricsEnabled && this.ipAsnFilename) { + commandArguments.push('-ip_asn_db', this.ipAsnFilename); + } if (this.verbose) { commandArguments.push('-verbose'); } @@ -99,14 +131,14 @@ export class OutlineShadowsocksServer implements ShadowsocksServer { commandArguments.push('--replay_history=10000'); } logging.info('======== Starting Outline Shadowsocks Service ========'); - logging.info(`${this.binaryFilename} ${commandArguments.map(a => `"${a}"`).join(' ')}`); + logging.info(`${this.binaryFilename} ${commandArguments.map((a) => `"${a}"`).join(' ')}`); this.ssProcess = child_process.spawn(this.binaryFilename, commandArguments); this.ssProcess.on('error', (error) => { logging.error(`Error spawning outline-ss-server: ${error}`); }); this.ssProcess.on('exit', (code, signal) => { logging.info(`outline-ss-server has exited with error. Code: ${code}, Signal: ${signal}`); - logging.info(`Restarting`); + logging.info('Restarting'); this.start(); }); // This exposes the outline-ss-server output on the docker logs. diff --git a/src/shadowbox/server/server_config.ts b/src/shadowbox/server/server_config.ts index 2e757abeb..e131f5608 100644 --- a/src/shadowbox/server/server_config.ts +++ b/src/shadowbox/server/server_config.ts @@ -37,6 +37,12 @@ export interface ServerConfigJson { hostname?: string; // Default data transfer limit applied to all access keys. accessKeyDataLimit?: DataLimit; + + // Experimental configuration options that are expected to be short-lived. + experimental?: { + // Whether ASN metric annotation for Prometheus is enabled. + asnMetricsEnabled?: boolean; + }; } // Serialized format for rollouts. diff --git a/third_party/outline-ss-server/Makefile b/third_party/outline-ss-server/Makefile index c780a577a..3bc26c30e 100644 --- a/third_party/outline-ss-server/Makefile +++ b/third_party/outline-ss-server/Makefile @@ -1,19 +1,19 @@ -VERSION=1.4.0 +VERSION=1.5.0 .PHONY: all all: bin/linux-x86_64/outline-ss-server bin/linux-arm64/outline-ss-server bin/macos-x86_64/outline-ss-server bin/macos-arm64/outline-ss-server bin/linux-x86_64/outline-ss-server: OS=linux -bin/linux-x86_64/outline-ss-server: SHA256=f51bcb6391cca0ae828620c429e698a3b7c409de2374c52f113ca9a525e021a8 +bin/linux-x86_64/outline-ss-server: SHA256=0c6439242afbea191281404f08ef33490b01d6d0413ccca00004c8a1927de49a bin/linux-arm64/outline-ss-server: OS=linux -bin/linux-arm64/outline-ss-server: SHA256=14ae581414c9aab04253a385ef1854c003d09f545f6f8a3a55aa987f0c6d3859 +bin/linux-arm64/outline-ss-server: SHA256=a643b28c2a894af6ceb1d309bf742092719877ede85ead6e8cbbc7b64b35a7ab bin/macos-x86_64/outline-ss-server: OS=macos -bin/macos-x86_64/outline-ss-server: SHA256=c85b2e8ae2d48482cbc101e54dcb7eed074a22c14a3a7301993e5f786b34081d +bin/macos-x86_64/outline-ss-server: SHA256=f4b034f74701e9dae52bc7c8660e875f81473ef6d535a1470967e887f5beb9c6 bin/macos-arm64/outline-ss-server: OS=macos -bin/macos-arm64/outline-ss-server: SHA256=9647712a7c732184f98b1e2e7f74281855afed2245ec922c4a24b54f0eb0ce72 +bin/macos-arm64/outline-ss-server: SHA256=1f1d1833935ba363a8c468cd61e90d42de7f16e7332346b3c80f389c914192d3 TEMPFILE := $(shell mktemp) bin/%/outline-ss-server: