├── .browserslistrc
├── .clang-format
├── .dockerignore
├── .editorconfig
├── .eslintignore
├── .eslintrc.json
├── .gitallowed
├── .github
├── CODEOWNERS
├── codeql
│ └── config.yml
└── workflows
│ ├── build_and_test_debug.yml
│ ├── codeql_vulnerability_analysis.yml
│ ├── license.yml
│ └── pull_request_checks.yml
├── .gitignore
├── .npmrc
├── .nvmrc
├── .prettierignore
├── .prettierrc.json
├── .shellcheckrc
├── CONTRIBUTING.md
├── LICENSE
├── README.md
├── Taskfile.yml
├── commitlint.config.js
├── docs
└── shadowsocks.md
├── go.mod
├── go.sum
├── jasmine.json
├── package-lock.json
├── package.json
├── scripts
└── shellcheck.sh
├── src
├── build
│ ├── download_file.mjs
│ ├── get_file_checksum.mjs
│ └── get_root_dir.mjs
├── metrics_server
│ ├── README.md
│ ├── Taskfile.yml
│ ├── app_dev.yaml
│ ├── app_prod.yaml
│ ├── config_dev.json
│ ├── config_prod.json
│ ├── connection_metrics.spec.ts
│ ├── connection_metrics.ts
│ ├── dispatch.yaml
│ ├── feature_metrics.spec.ts
│ ├── feature_metrics.ts
│ ├── index.ts
│ ├── infrastructure
│ │ └── table.ts
│ ├── model.ts
│ ├── package.json
│ ├── test_integration.sh
│ └── tsconfig.json
├── sentry_webhook
│ ├── README.md
│ ├── Taskfile.yml
│ ├── event.ts
│ ├── index.ts
│ ├── karma.conf.js
│ ├── package.json
│ ├── post_sentry_event_to_salesforce.spec.ts
│ ├── post_sentry_event_to_salesforce.ts
│ ├── test.webpack.js
│ ├── tsconfig.json
│ └── tsconfig.prod.json
├── server_manager
│ ├── README.md
│ └── install_scripts
│ │ └── install_server.sh
└── shadowbox
│ ├── CHANGELOG.md
│ ├── README.md
│ ├── Taskfile.yml
│ ├── docker
│ ├── Dockerfile
│ └── cmd.sh
│ ├── infrastructure
│ ├── clock.ts
│ ├── file.spec.ts
│ ├── file.ts
│ ├── filesystem_text_file.ts
│ ├── follow_redirects.ts
│ ├── get_port.spec.ts
│ ├── get_port.ts
│ ├── json_config.ts
│ ├── logging.ts
│ ├── prometheus_scraper.ts
│ ├── rollout.spec.ts
│ ├── rollout.ts
│ └── text_file.ts
│ ├── integration_test
│ ├── README.md
│ ├── client
│ │ └── Dockerfile
│ ├── target
│ │ ├── Dockerfile
│ │ └── index.html
│ └── test.sh
│ ├── model
│ ├── access_key.ts
│ ├── errors.ts
│ ├── metrics.ts
│ └── shadowsocks_server.ts
│ ├── package.json
│ ├── scripts
│ ├── make_test_certificate.sh
│ └── update_mmdb.sh
│ ├── server
│ ├── api.yml
│ ├── main.ts
│ ├── manager_metrics.spec.ts
│ ├── manager_metrics.ts
│ ├── manager_service.spec.ts
│ ├── manager_service.ts
│ ├── mocks
│ │ └── mocks.ts
│ ├── outline_shadowsocks_server.ts
│ ├── server_access_key.spec.ts
│ ├── server_access_key.ts
│ ├── server_config.ts
│ ├── shared_metrics.spec.ts
│ ├── shared_metrics.ts
│ └── version.ts
│ ├── shadowbox_config.json
│ ├── tsconfig.json
│ ├── types
│ └── node.d.ts
│ └── webpack.config.js
├── third_party
├── Taskfile.yml
├── prometheus
│ ├── .gitignore
│ ├── LICENSE
│ ├── METADATA
│ └── NOTICE
└── shellcheck
│ ├── README.md
│ ├── hashes.sha256
│ └── run.sh
├── tools.go
└── tsconfig.json
/.browserslistrc:
--------------------------------------------------------------------------------
1 | # We use Electron v18.
2 | # This is the version of Chrome it requires:
3 | # https://github.com/electron/releases#releases
4 | chrome >= 100
5 |
--------------------------------------------------------------------------------
/.clang-format:
--------------------------------------------------------------------------------
1 | BasedOnStyle: Google
2 | ColumnLimit: 100
3 |
--------------------------------------------------------------------------------
/.dockerignore:
--------------------------------------------------------------------------------
1 | node_modules
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | root = true
2 |
3 | [*]
4 | charset = utf-8
5 | indent_size = 2
6 | indent_style = space
7 | trim_trailing_whitespace = true
8 |
9 | [*.md]
10 | trim_trailing_whitespace = false
11 |
--------------------------------------------------------------------------------
/.eslintignore:
--------------------------------------------------------------------------------
1 | /build/
2 | node_modules/
3 |
--------------------------------------------------------------------------------
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "env": {
3 | "browser": true,
4 | "es2021": true,
5 | "node": true
6 | },
7 | "extends": [
8 | "eslint:recommended",
9 | "plugin:@typescript-eslint/recommended",
10 | "plugin:import/typescript"
11 | ],
12 | "parser": "@typescript-eslint/parser",
13 | "parserOptions": {
14 | "ecmaVersion": "latest",
15 | "sourceType": "module"
16 | },
17 | "plugins": ["@typescript-eslint", "compat", "import"],
18 | "rules": {
19 | "compat/compat": "error",
20 | "import/no-restricted-paths": [
21 | "error",
22 | {
23 | "zones": [
24 | // this means that in the src/shadowbox/infrastructure folder,
25 | // you can't import any code,
26 | // with the exception of other files within the src/shadowbox/infrastructure folder
27 | {
28 | "target": "./src/shadowbox/infrastructure",
29 | "from": ".",
30 | "except": ["./src/shadowbox/infrastructure", "./node_modules"]
31 | },
32 | {
33 | "target": "./src/server_manager/infrastructure",
34 | "from": ".",
35 | "except": ["./src/server_manager/infrastructure", "./node_modules"]
36 | },
37 | {
38 | "target": "./src/metrics_server/infrastructure",
39 | "from": ".",
40 | "except": ["./src/metrics_server/infrastructure", "./node_modules"]
41 | },
42 | // similar to above but for src/shadowbox/model, but you can use files from both the
43 | // src/shadowbox/model and src/shadowbox/infrastructure paths
44 | {
45 | "target": "./src/shadowbox/model",
46 | "from": ".",
47 | "except": ["./src/shadowbox/model", "./src/shadowbox/infrastructure", "./node_modules"]
48 | },
49 | {
50 | "target": "./src/server_manager/model",
51 | "from": ".",
52 | "except": [
53 | "./src/server_manager/model",
54 | "./src/server_manager/infrastructure",
55 | "./node_modules"
56 | ]
57 | }
58 | // TODO(daniellacosse): fix ui_component-specific import violations
59 | // {
60 | // "target": "./src/server_manager/web_app/ui_components",
61 | // "from": "./src/server_manager/model"
62 | // },
63 | // {
64 | // "target": "./src/server_manager/web_app/ui_components",
65 | // "from": "./src/server_manager/web_app",
66 | // "except": ["./ui_components"]
67 | // }
68 | ]
69 | }
70 | ],
71 | "no-prototype-builtins": "off",
72 | "@typescript-eslint/ban-types": "off",
73 | "@typescript-eslint/explicit-member-accessibility": [
74 | "error",
75 | {
76 | "accessibility": "no-public"
77 | }
78 | ],
79 | "@typescript-eslint/explicit-module-boundary-types": "off",
80 | "@typescript-eslint/no-empty-function": "off",
81 | "@typescript-eslint/no-explicit-any": "error",
82 | "@typescript-eslint/no-non-null-assertion": "off",
83 | "@typescript-eslint/no-unused-vars": [
84 | "warn",
85 | {
86 | "argsIgnorePattern": "^_"
87 | }
88 | ]
89 | },
90 | "overrides": [
91 | {
92 | "files": [
93 | "check-version-tracker.js",
94 | "rollup-common.js",
95 | "rollup.config.js",
96 | "web-test-runner.config.js"
97 | ],
98 | "env": {
99 | "node": true
100 | }
101 | },
102 | {
103 | "files": ["packages/lit-html/src/test/version-stability_test.js"],
104 | "env": {
105 | "mocha": true
106 | }
107 | },
108 | {
109 | "files": [
110 | "*_test.ts",
111 | "packages/labs/ssr/custom_typings/node.d.ts",
112 | "packages/labs/ssr/src/test/integration/tests/**",
113 | "packages/labs/ssr/src/lib/util/parse5-utils.ts"
114 | ],
115 | "rules": {
116 | "@typescript-eslint/no-explicit-any": "off"
117 | }
118 | }
119 | ]
120 | }
121 |
--------------------------------------------------------------------------------
/.gitallowed:
--------------------------------------------------------------------------------
1 | 946220775492-a5v6bsdin6o7ncnqn34snuatmrp7dqh0.apps.googleusercontent.com
2 |
--------------------------------------------------------------------------------
/.github/CODEOWNERS:
--------------------------------------------------------------------------------
1 | * @Jigsaw-Code/outline-dev
2 |
3 | /src/server_manager/model/ @fortuna
4 | /src/shadowbox/ @fortuna
5 |
--------------------------------------------------------------------------------
/.github/codeql/config.yml:
--------------------------------------------------------------------------------
1 | # Copyright 2024 The Outline Authors
2 | #
3 | # Licensed under the Apache License, Version 2.0 (the "License");
4 | # you may not use this file except in compliance with the License.
5 | # You may obtain a copy of the License at
6 | #
7 | # http://www.apache.org/licenses/LICENSE-2.0
8 | #
9 | # Unless required by applicable law or agreed to in writing, software
10 | # distributed under the License is distributed on an "AS IS" BASIS,
11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | # See the License for the specific language governing permissions and
13 | # limitations under the License.
14 |
15 | query-filters:
16 | - exclude:
17 | id: js/disabling-certificate-validation
18 | - exclude:
19 | id: js/missing-rate-limiting
20 |
--------------------------------------------------------------------------------
/.github/workflows/build_and_test_debug.yml:
--------------------------------------------------------------------------------
1 | # Copyright 2024 The Outline Authors
2 | #
3 | # Licensed under the Apache License, Version 2.0 (the "License");
4 | # you may not use this file except in compliance with the License.
5 | # You may obtain a copy of the License at
6 | #
7 | # http://www.apache.org/licenses/LICENSE-2.0
8 | #
9 | # Unless required by applicable law or agreed to in writing, software
10 | # distributed under the License is distributed on an "AS IS" BASIS,
11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | # See the License for the specific language governing permissions and
13 | # limitations under the License.
14 |
15 | name: Build and Test
16 |
17 | concurrency:
18 | group: ${{ github.head_ref || github.ref }}
19 | cancel-in-progress: true
20 |
21 | on:
22 | pull_request:
23 | types:
24 | - opened
25 | - synchronize
26 | push:
27 | branches:
28 | - master
29 | schedule:
30 | - cron: "0 13 * * *" # Run daily at 1PM UTC.
31 |
32 | jobs:
33 | lint:
34 | name: Lint
35 | runs-on: ubuntu-latest
36 | steps:
37 | - name: Checkout
38 | uses: actions/checkout@v2.3.4
39 |
40 | - name: Install Node
41 | uses: actions/setup-node@v3
42 | with:
43 | node-version: 18
44 | cache: npm
45 |
46 | - name: Install NPM Dependencies
47 | run: npm ci
48 |
49 | - name: Lint
50 | run: ./task lint
51 |
52 | shadowbox:
53 | name: Shadowbox
54 | runs-on: ubuntu-latest
55 | needs: lint
56 | steps:
57 | - name: Checkout
58 | uses: actions/checkout@v2.3.4
59 |
60 | - name: Install Node
61 | uses: actions/setup-node@v3
62 | with:
63 | node-version: 18
64 | cache: npm
65 |
66 | - name: Install NPM Dependencies
67 | run: npm ci
68 |
69 | - name: Shadowbox Debug Build
70 | run: ./task shadowbox:build
71 |
72 | - name: Shadowbox Unit Test
73 | run: ./task shadowbox:test
74 |
75 | - name: Shadowbox Integration Test
76 | run: ./task shadowbox:integration_test
77 |
78 | - name: Verify Open API Spec
79 | uses: mbowman100/swagger-validator-action@master
80 | with:
81 | files: |
82 | src/shadowbox/server/api.yml
83 |
84 | manual-install-script:
85 | name: Manual Install Script
86 | runs-on: ubuntu-latest
87 | steps:
88 | - name: Checkout
89 | uses: actions/checkout@v2.3.4
90 |
91 | - name: Install Outline Server
92 | run: ./src/server_manager/install_scripts/install_server.sh --hostname localhost
93 |
94 | - name: Test API
95 | run: 'curl --silent --fail --insecure $(grep "apiUrl" /opt/outline/access.txt | cut -d: -f 2-)/server'
96 |
97 | metrics-server:
98 | name: Metrics Server
99 | runs-on: ubuntu-latest
100 | needs: lint
101 | steps:
102 | - name: Checkout
103 | uses: actions/checkout@v2.3.4
104 |
105 | - name: Install Node
106 | uses: actions/setup-node@v3
107 | with:
108 | node-version: 18
109 | cache: npm
110 |
111 | - name: Install NPM Dependencies
112 | run: npm ci
113 |
114 | - name: Metrics Server Debug Build
115 | run: ./task metrics_server:build
116 |
117 | - name: Metrics Server Test
118 | run: ./task metrics_server:test
119 |
120 | sentry-webhook:
121 | name: Sentry Webhook
122 | # TODO(puppeteer/puppeteer#12818): Update when chromium bug is resolved.
123 | runs-on: ubuntu-22.04
124 | needs: lint
125 | steps:
126 | - name: Checkout
127 | uses: actions/checkout@v2.3.4
128 |
129 | - name: Install Node
130 | uses: actions/setup-node@v3
131 | with:
132 | node-version: 18
133 | cache: npm
134 |
135 | - name: Install NPM Dependencies
136 | run: npm ci
137 |
138 | - name: Sentry Webhook Debug Build
139 | run: ./task sentry_webhook:build
140 |
141 | - name: Sentry Webhook Test
142 | run: ./task sentry_webhook:test
143 |
--------------------------------------------------------------------------------
/.github/workflows/codeql_vulnerability_analysis.yml:
--------------------------------------------------------------------------------
1 | # Copyright 2024 The Outline Authors
2 | #
3 | # Licensed under the Apache License, Version 2.0 (the "License");
4 | # you may not use this file except in compliance with the License.
5 | # You may obtain a copy of the License at
6 | #
7 | # http://www.apache.org/licenses/LICENSE-2.0
8 | #
9 | # Unless required by applicable law or agreed to in writing, software
10 | # distributed under the License is distributed on an "AS IS" BASIS,
11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | # See the License for the specific language governing permissions and
13 | # limitations under the License.
14 |
15 | name: "CodeQL analysis"
16 |
17 | on:
18 | pull_request:
19 | types:
20 | - opened
21 | - edited
22 | - synchronize
23 | push:
24 | branches:
25 | - master
26 | schedule:
27 | - cron: '0 0 * * *'
28 |
29 | jobs:
30 | analyze:
31 | name: Analyze
32 | runs-on: ubuntu-latest
33 |
34 | permissions:
35 | actions: read
36 | contents: read
37 | security-events: write
38 |
39 | strategy:
40 | fail-fast: false
41 | steps:
42 | - name: Checkout repository
43 | uses: actions/checkout@v2
44 |
45 | - name: Initialize CodeQL
46 | uses: github/codeql-action/init@v2
47 | with:
48 | languages: javascript
49 | queries: +security-and-quality
50 | config-file: ./.github/codeql/config.yml
51 |
52 | - name: Perform CodeQL Analysis
53 | uses: github/codeql-action/analyze@v2
54 |
--------------------------------------------------------------------------------
/.github/workflows/license.yml:
--------------------------------------------------------------------------------
1 | # Copyright 2024 The Outline Authors
2 | #
3 | # Licensed under the Apache License, Version 2.0 (the "License");
4 | # you may not use this file except in compliance with the License.
5 | # You may obtain a copy of the License at
6 | #
7 | # http://www.apache.org/licenses/LICENSE-2.0
8 | #
9 | # Unless required by applicable law or agreed to in writing, software
10 | # distributed under the License is distributed on an "AS IS" BASIS,
11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | # See the License for the specific language governing permissions and
13 | # limitations under the License.
14 |
15 | name: License checks
16 |
17 | concurrency:
18 | group: ${{ github.head_ref || github.ref }}
19 | cancel-in-progress: true
20 |
21 | on:
22 | pull_request:
23 | types:
24 | - opened
25 | - synchronize
26 | push:
27 | branches:
28 | - master
29 |
30 | jobs:
31 | license-check:
32 | runs-on: ubuntu-latest
33 | steps:
34 | - name: Check out code into the Go module directory
35 | uses: actions/checkout@v3
36 |
37 | - name: Set up Go
38 | uses: actions/setup-go@v4
39 | with:
40 | go-version-file: '${{ github.workspace }}/go.mod'
41 |
42 | - name: Check license headers
43 | run: go run github.com/google/addlicense -check -l apache -c 'The Outline Authors' -ignore "third_party/**" -v .
44 |
--------------------------------------------------------------------------------
/.github/workflows/pull_request_checks.yml:
--------------------------------------------------------------------------------
1 | # Copyright 2024 The Outline Authors
2 | #
3 | # Licensed under the Apache License, Version 2.0 (the "License");
4 | # you may not use this file except in compliance with the License.
5 | # You may obtain a copy of the License at
6 | #
7 | # http://www.apache.org/licenses/LICENSE-2.0
8 | #
9 | # Unless required by applicable law or agreed to in writing, software
10 | # distributed under the License is distributed on an "AS IS" BASIS,
11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | # See the License for the specific language governing permissions and
13 | # limitations under the License.
14 |
15 | name: Pull Request Checks
16 |
17 | on:
18 | pull_request:
19 | types:
20 | - opened
21 |
22 | # This `edited` flag is why we need a separate workflow -
23 | # specifying edited here causes this job to be re-run whenever someone edits the PR title/description.
24 |
25 | # If we had the debug builds in this file, they would run unnecessarily, costing resources.
26 | - edited
27 |
28 | - synchronize
29 |
30 | jobs:
31 | name_check:
32 | name: Pull Request Name Check
33 | runs-on: ubuntu-latest
34 | permissions:
35 | pull-requests: read
36 | env:
37 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
38 | steps:
39 | - name: Clone Repository
40 | uses: actions/checkout@v2
41 |
42 | - name: Install Node
43 | uses: actions/setup-node@v2.2.0
44 | with:
45 | node-version: 18
46 | cache: npm
47 |
48 | - name: Install NPM Dependencies
49 | run: npm ci
50 |
51 | - name: Ensure Commitizen Format
52 | uses: JulienKode/pull-request-name-linter-action@98794a8b815ec05560813c42e55fd8d32d3fd248
53 |
54 | size_label:
55 | name: Change Size Label
56 | runs-on: ubuntu-latest
57 | permissions:
58 | contents: read
59 | pull-requests: write
60 | env:
61 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
62 | steps:
63 | # size-label-action fails to work when coming from a fork:
64 | # https://github.com/pascalgn/size-label-action/issues/10
65 | - if: ${{ !github.event.pull_request.head.repo.fork }}
66 | name: Apply Size Label
67 | uses: pascalgn/size-label-action@a4655c448bb838e8d73b81e97fd0831bb4cbda1e
68 | env:
69 | IGNORED: |
70 | LICENSE
71 | package-lock.json
72 | third_party/*
73 | with:
74 | sizes: >
75 | {
76 | "0": "XS",
77 | "64": "S",
78 | "128": "M",
79 | "256": "L",
80 | "512": "XL",
81 | "1024": "XXL"
82 | }
83 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | .idea/
3 | .task/
4 | .vscode/
5 | /build/
6 | /src/server_manager/install_scripts/do_install_script.ts
7 | /src/server_manager/install_scripts/gcp_install_script.ts
8 | /task
9 | macos-signing-certificate.p12
10 | node_modules/
11 | third_party/shellcheck/download/
--------------------------------------------------------------------------------
/.npmrc:
--------------------------------------------------------------------------------
1 | ; Enforces that the user is `npm install`ing with the correct node version.
2 | engine-strict=true
3 |
4 | ; Workaround for conflict between the default location(s) of node-forge and the
5 | ; location expected by Typescript, Jasmine, and Electron.
6 | prefer-dedupe=true
7 |
--------------------------------------------------------------------------------
/.nvmrc:
--------------------------------------------------------------------------------
1 | lts/hydrogen
2 |
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | /build/
2 | node_modules/
3 | /src/server_manager/messages/
4 |
--------------------------------------------------------------------------------
/.prettierrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "singleQuote": true,
3 | "bracketSpacing": false,
4 | "printWidth": 100
5 | }
6 |
--------------------------------------------------------------------------------
/.shellcheckrc:
--------------------------------------------------------------------------------
1 | # Configuration for `npx shellcheck` and IDEs
2 |
3 | # Enable relative path references
4 | source-path=SCRIPTDIR
5 |
6 | # The style guide says "quote your variables"
7 | enable=quote-safe-variables
8 |
9 | # The style guide says 'prefer "${var}" over "$var"'
10 | enable=require-variable-braces
11 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # How to Contribute
2 |
3 | We'd love to accept your patches and contributions to this project. There are
4 | just a few small guidelines you need to follow.
5 |
6 | ## Before you begin
7 |
8 | ### Contributor License Agreement
9 |
10 | Contributions to this project must be accompanied by a Contributor License
11 | Agreement. You (or your employer) retain the copyright to your contribution,
12 | this simply gives us permission to use and redistribute your contributions as
13 | part of the project. Head over to to see
14 | your current agreements on file or to sign a new one.
15 |
16 | You generally only need to submit a CLA once, so if you've already submitted one
17 | (even if it was for a different project), you probably don't need to do it
18 | again.
19 |
20 | ### Code reviews
21 |
22 | All submissions, including submissions by project members, require review. We
23 | use GitHub pull requests for this purpose. Consult
24 | [GitHub Help](https://help.github.com/articles/about-pull-requests/) for more
25 | information on using pull requests.
26 |
27 | ## Build Tasks
28 |
29 | We use [Task](https://taskfile.dev/) to run tasks. [Install Task](https://taskfile.dev/installation/) and make sure it's in your `PATH`. Then you can run tasks with:
30 |
31 | ```sh
32 | task [task_name]
33 | ```
34 |
35 | To list all available tasks:
36 |
37 | ```sh
38 | task -a
39 | ```
40 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Outline Server
2 |
3 |  [](https://community.internetfreedomfestival.org/community/channels/outline-community) [](https://www.reddit.com/r/outlinevpn/)
4 |
5 | Outline Server is the component that provides the Shadowsocks service (via [outline-ss-server](https://github.com/Jigsaw-Code/outline-ss-server/)) and a service management API. You can deploy this server directly following simple instructions in this repository, or if you prefer a ready-to-use graphical interface you can use the [Outline Manager](https://github.com/Jigsaw-Code/outline-apps/).
6 |
7 | **Components:**
8 |
9 | - **Outline Server** ([`src/shadowbox`](src/shadowbox)): The core proxy server that runs and manages [outline-ss-server](https://github.com/Jigsaw-Code/outline-ss-server/), a Shadowsocks backend. It provides a REST API for access key management.
10 |
11 | - **Metrics Server** ([`src/metrics_server`](src/metrics_server)): A REST service for optional, anonymous metrics sharing.
12 |
13 | **Join the Outline Community** by signing up for the [IFF Mattermost](https://wiki.digitalrights.community/index.php?title=IFF_Mattermost)!
14 |
15 | ## Shadowsocks and Anti-Censorship
16 |
17 | Outline's use of Shadowsocks means it benefits from ongoing improvements that strengthen its resistance against detection and blocking.
18 |
19 | **Key Protections:**
20 |
21 | - **AEAD ciphers** are mandatory.
22 | - **Probing resistance** mitigates detection techniques.
23 | - **Protection against replayed data.**
24 | - **Variable packet sizes** to hinder identification.
25 |
26 | See [Shadowsocks resistance against detection and blocking](docs/shadowsocks.md).
27 |
28 | ## Installation
29 |
30 | **Prerequisites**
31 |
32 | - [Node](https://nodejs.org/en/download/) LTS (`lts/hydrogen`, version `18.16.0`)
33 | - [NPM](https://docs.npmjs.com/downloading-and-installing-node-js-and-npm) (version `9.5.1`)
34 | - [Go](https://go.dev/dl/) 1.21+
35 |
36 | 1. **Install dependencies**
37 |
38 | ```sh
39 | npm install
40 | ```
41 |
42 | 1. **Start the server**
43 |
44 | ```sh
45 | ./task shadowbox:start
46 | ```
47 |
48 | Exploring further options:
49 |
50 | - **Refer to the README:** Find additional configuration and usage options in the core server's [`README`](src/shadowbox/README.md).
51 | - **Learn about the build system:** For in-depth build system information, consult the [contributing guide](CONTRIBUTING.md).
52 |
53 | 1. **To clean up**
54 |
55 | ```sh
56 | ./task clean
57 | ```
58 |
--------------------------------------------------------------------------------
/Taskfile.yml:
--------------------------------------------------------------------------------
1 | # Copyright 2024 The Outline Authors
2 | #
3 | # Licensed under the Apache License, Version 2.0 (the "License");
4 | # you may not use this file except in compliance with the License.
5 | # You may obtain a copy of the License at
6 | #
7 | # http://www.apache.org/licenses/LICENSE-2.0
8 | #
9 | # Unless required by applicable law or agreed to in writing, software
10 | # distributed under the License is distributed on an "AS IS" BASIS,
11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | # See the License for the specific language governing permissions and
13 | # limitations under the License.
14 |
15 | version: '3'
16 |
17 | set: [pipefail]
18 |
19 | run: when_changed
20 |
21 | vars:
22 | REPO_ROOT: "{{.ROOT_DIR}}"
23 | BUILD_ROOT: "{{.ROOT_DIR}}/build"
24 | DOCKER: '{{.DOCKER | default "docker"}}'
25 |
26 | includes:
27 | metrics_server:
28 | taskfile: ./src/metrics_server/Taskfile.yml
29 | vars: {OUTPUT_BASE: '{{joinPath .BUILD_ROOT "metrics_server"}}'}
30 |
31 | sentry_webhook:
32 | taskfile: ./src/sentry_webhook/Taskfile.yml
33 | vars: {OUTPUT_BASE: '{{joinPath .BUILD_ROOT "sentry_webhook"}}'}
34 |
35 | shadowbox:
36 | taskfile: ./src/shadowbox/Taskfile.yml
37 | vars: {OUTPUT_BASE: '{{joinPath .BUILD_ROOT "shadowbox"}}'}
38 |
39 | third_party:
40 | taskfile: ./third_party/Taskfile.yml
41 | vars: {OUTPUT_BASE: '{{joinPath .BUILD_ROOT "third_party"}}'}
42 |
43 | tasks:
44 | clean:
45 | desc: Clean output files
46 | cmds:
47 | - rm -rf .task task src/*/node_modules/ build/ node_modules/ third_party/shellcheck/download/ third_party/*/bin
48 |
49 | format:
50 | desc: Format staged files
51 | cmds: ['npx pretty-quick --staged --pattern "**/*.{cjs,html,js,json,md,ts}"']
52 |
53 | format:all:
54 | desc: Format all files in the repository
55 | cmds: ['npx prettier --write "**/*.{cjs,html,js,json,md,ts}"']
56 |
57 | lint:
58 | desc: Lint all files
59 | deps: [lint:sh, lint:ts]
60 |
61 | lint:sh:
62 | desc: Lint all shell files
63 | cmds: [bash ./scripts/shellcheck.sh]
64 |
65 | lint:ts:
66 | desc: Lint all .ts and .js files
67 | cmds: ['npx eslint "**/*.{js,ts}"']
68 |
69 | test:
70 | desc: Run all the repository tests
71 | deps: [lint, metrics_server:test, sentry_webhook:test, shadowbox:test]
72 |
--------------------------------------------------------------------------------
/commitlint.config.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright 2024 The Outline Authors
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | module.exports = {
18 | extends: ['@commitlint/config-conventional'],
19 | rules: {
20 | 'scope-enum': [
21 | 2,
22 | 'always',
23 | ['devtools', 'devtools/build', 'docs', 'metrics_server', 'sentry_webhook', 'server'],
24 | ],
25 | },
26 | };
27 |
--------------------------------------------------------------------------------
/docs/shadowsocks.md:
--------------------------------------------------------------------------------
1 | # Shadowsocks Resistance Against Detection and Blocking
2 |
3 | Shadowsocks used to be blocked in some countries, and because Outline uses Shadowsocks, there has been skepticism about Outline working in those countries. In fact, people have tried Outline in the past and had their servers blocked.
4 |
5 | However, since the second half of 2020 things have changed. The Outline team and Shadowsocks community made a number of improvements that strengthened Shadowsocks beyond the censor's current capabilities.
6 |
7 | As shown in the research [How China Detects and Blocks Shadowsocks](https://gfw.report/talks/imc20/en/), the censor uses active probing to detect Shadowsocks servers. The probing may be triggered by packet sniffing, but that's not how the servers are detected.
8 |
9 | Even though Shadowsocks is a standard, it leaves a lot of room for choices on how it's implemented and deployed.
10 |
11 | First of all, you **must use AEAD ciphers**. The old stream ciphers are easy to break and manipulate, exposing you to simple detection and decryption attacks. Outline has banned all stream ciphers, since people copy old examples to set up their servers. The Outline Manager goes further and picks the cipher for you, since users don't usually know how to choose a cipher, and it generates a long random secret, so you are not vulnerable to dictionary-based attacks.
12 |
13 | Second, you need **probing resistance**. Both shadowsocks-libev and Outline have added that. The research [Detecting Probe-resistant Proxies](https://www.ndss-symposium.org/ndss-paper/detecting-probe-resistant-proxies/) showed that, in the past, an invalid byte would trigger different behaviors whether it was inserted in positions 49, 50 or 51 of the stream, which is very telling. That behavior is now gone, and the censor can no longer rely on that.
14 |
15 | Third, you need **protection against replayed data**. Both shadowsocks-libev and Outline have added such protection, which you may need to enable explicitly on ss-libev, but it's the default on Outline.
16 |
17 | Fourth, Outline and clients using shadowsocks-libev now **merge the SOCKS address and the initial data** in the same initial encrypted frame, making the size of the first packet variable. Before the first packet only had the SOCKS address, with a fixed size, and that was a giveaway.
18 |
19 | The censors used to block Shadowsocks, but Shadowsocks has evolved, and in 2021, it was ahead again in the cat and mouse game.
20 |
21 | In 2022 China started blocking seemingly random traffic ([report](https://www.opentech.fund/news/exposing-the-great-firewalls-dynamic-blocking-of-fully-encrypted-traffic/)). While there is no evidence they could detect Shadowsocks, the protocol ended up blocked.
22 |
23 | As a reponse, we [added a feature to the Outline Client](https://github.com/Jigsaw-Code/outline-apps/pull/1454) that allows service managers to specify a **[connection prefix disguise](https://www.reddit.com/r/outlinevpn/wiki/index/prefixing/)** to be used in the Shadowsocks initialization, which can be used to bypass the blocking in China by making it look like a protocol that is allowed.
24 |
25 | Shadowsocks remains our protocol of choice because it's simple, well understood and very performant. Furthermore, it has an enthusiastic community of very smart people behind it.
26 |
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module localhost
2 |
3 | go 1.21
4 |
5 | require (
6 | github.com/Jigsaw-Code/outline-ss-server v1.7.3
7 | github.com/go-task/task/v3 v3.36.0
8 | github.com/google/addlicense v1.1.1
9 | )
10 |
11 | require (
12 | github.com/Jigsaw-Code/outline-sdk v0.0.14 // indirect
13 | github.com/Masterminds/semver/v3 v3.2.1 // indirect
14 | github.com/beorn7/perks v1.0.1 // indirect
15 | github.com/bmatcuk/doublestar/v4 v4.0.2 // indirect
16 | github.com/cespare/xxhash/v2 v2.2.0 // indirect
17 | github.com/davecgh/go-spew v1.1.1 // indirect
18 | github.com/fatih/color v1.16.0 // indirect
19 | github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
20 | github.com/golang/protobuf v1.5.3 // indirect
21 | github.com/joho/godotenv v1.5.1 // indirect
22 | github.com/klauspost/cpuid/v2 v2.0.9 // indirect
23 | github.com/lmittmann/tint v1.0.5 // indirect
24 | github.com/mattn/go-colorable v0.1.13 // indirect
25 | github.com/mattn/go-isatty v0.0.20 // indirect
26 | github.com/mattn/go-zglob v0.0.4 // indirect
27 | github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect
28 | github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
29 | github.com/oschwald/geoip2-golang v1.8.0 // indirect
30 | github.com/oschwald/maxminddb-golang v1.10.0 // indirect
31 | github.com/prometheus/client_golang v1.15.0 // indirect
32 | github.com/prometheus/client_model v0.3.0 // indirect
33 | github.com/prometheus/common v0.42.0 // indirect
34 | github.com/prometheus/procfs v0.9.0 // indirect
35 | github.com/radovskyb/watcher v1.0.7 // indirect
36 | github.com/sajari/fuzzy v1.0.0 // indirect
37 | github.com/shadowsocks/go-shadowsocks2 v0.1.5 // indirect
38 | github.com/spf13/pflag v1.0.5 // indirect
39 | github.com/zeebo/xxh3 v1.0.2 // indirect
40 | golang.org/x/crypto v0.31.0 // indirect
41 | golang.org/x/sync v0.7.0 // indirect
42 | golang.org/x/sys v0.28.0 // indirect
43 | golang.org/x/term v0.27.0 // indirect
44 | google.golang.org/protobuf v1.30.0 // indirect
45 | gopkg.in/yaml.v3 v3.0.1 // indirect
46 | mvdan.cc/sh/v3 v3.8.0 // indirect
47 | )
48 |
--------------------------------------------------------------------------------
/jasmine.json:
--------------------------------------------------------------------------------
1 | {
2 | "spec_dir": ".",
3 | "spec_files": ["build/js/**/*.spec.js"],
4 | "stopSpecOnExpectationFailure": false,
5 | "random": false
6 | }
7 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "outline-server",
3 | "private": true,
4 | "dependencies": {
5 | "node-fetch": "^2.6.7"
6 | },
7 | "devDependencies": {
8 | "@commitlint/config-conventional": "^17.0.0",
9 | "@types/jasmine": "^3.5.10",
10 | "@types/node-fetch": "^2.6.2",
11 | "@typescript-eslint/eslint-plugin": "^7.7.0",
12 | "@typescript-eslint/parser": "^7.7.0",
13 | "@webpack-cli/serve": "^2.0.5",
14 | "browserslist": "^4.20.3",
15 | "eslint": "^8.10.0",
16 | "eslint-import-resolver-typescript": "^2.7.1",
17 | "eslint-plugin-compat": "^4.0.2",
18 | "eslint-plugin-import": "^2.26.0",
19 | "generate-license-file": "^1.2.0",
20 | "husky": "^1.3.1",
21 | "jasmine": "^3.5.0",
22 | "minimist": "^1.2.8",
23 | "prettier": "^2.4.1",
24 | "pretty-quick": "^3.1.1",
25 | "typescript": "^4.9.5",
26 | "webpack-cli": "^5.1.4"
27 | },
28 | "scripts": {
29 | "postinstall": "go build github.com/go-task/task/v3/cmd/task"
30 | },
31 | "engines": {
32 | "node": "18.x.x"
33 | },
34 | "workspaces": [
35 | "src/*"
36 | ],
37 | "husky": {
38 | "hooks": {
39 | "pre-commit": "./task lint format"
40 | }
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/scripts/shellcheck.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash -eu
2 | #
3 | # Copyright 2021 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.
16 |
17 | # This script intended to run at the repository root.
18 |
19 | readonly WRAPPER='third_party/shellcheck/run.sh'
20 | declare -ar START=(src scripts "${WRAPPER}")
21 |
22 | # From the specified starting points,
23 | # run shellcheck over all files ending in .sh.
24 | find "${START[@]}" -name '*.sh' -exec "${WRAPPER}" {} +
25 |
--------------------------------------------------------------------------------
/src/build/download_file.mjs:
--------------------------------------------------------------------------------
1 | // Copyright 2023 The Outline Authors
2 | //
3 | // Licensed under the Apache License, Version 2.0 (the "License");
4 | // you may not use this file except in compliance with the License.
5 | // You may obtain a copy of the License at
6 | //
7 | // http://www.apache.org/licenses/LICENSE-2.0
8 | //
9 | // Unless required by applicable law or agreed to in writing, software
10 | // distributed under the License is distributed on an "AS IS" BASIS,
11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | // See the License for the specific language governing permissions and
13 | // limitations under the License.
14 |
15 | import {createWriteStream} from 'node:fs';
16 | import {mkdir} from 'node:fs/promises';
17 | import {pipeline} from 'node:stream/promises';
18 | import * as path from 'path'
19 | import url from 'url';
20 |
21 | import fetch from 'node-fetch';
22 | import minimist from 'minimist';
23 |
24 | import {getFileChecksum} from './get_file_checksum.mjs'
25 |
26 | /**
27 | * Download a remote file from `fileUrl` and save it to `filepath`, using HTTPS protocol.
28 | * This function will also follow HTTP redirects.
29 | * @param {string} fileUrl The full URL of the remote resource.
30 | * @param {string} filepath The full path of the target file.
31 | * @param {string} sha256Checksum The SHA256 checksum of the file to use for verification.
32 | * @returns {Promise} A task that will be completed once the download is completed.
33 | */
34 | export async function downloadFile(fileUrl, filepath, sha256Checksum) {
35 | await mkdir(path.dirname(filepath), { recursive: true });
36 |
37 | const response = await fetch(fileUrl);
38 | if (!response.ok) {
39 | throw new Error(`failed to download "${fileUrl}": ${response.status} ${response.statusText}`);
40 | }
41 | const target = createWriteStream(filepath);
42 | await pipeline(response.body, target);
43 |
44 | const actualChecksum = await getFileChecksum(filepath, 'sha256');
45 | if (actualChecksum !== sha256Checksum) {
46 | throw new Error(`failed to verify "${filepath}". ` +
47 | `Expected checksum ${sha256Checksum}, but found ${actualChecksum}`);
48 | }
49 | }
50 |
51 | async function main(...args) {
52 | const {url, sha256, out} = minimist(args);
53 | await downloadFile(url, out, sha256)
54 | }
55 |
56 | if (import.meta.url === url.pathToFileURL(process.argv[1]).href) {
57 | await main(...process.argv.slice(2));
58 | }
59 |
--------------------------------------------------------------------------------
/src/build/get_file_checksum.mjs:
--------------------------------------------------------------------------------
1 | // Copyright 2023 The Outline Authors
2 | //
3 | // Licensed under the Apache License, Version 2.0 (the "License");
4 | // you may not use this file except in compliance with the License.
5 | // You may obtain a copy of the License at
6 | //
7 | // http://www.apache.org/licenses/LICENSE-2.0
8 | //
9 | // Unless required by applicable law or agreed to in writing, software
10 | // distributed under the License is distributed on an "AS IS" BASIS,
11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | // See the License for the specific language governing permissions and
13 | // limitations under the License.
14 |
15 | import {createHash} from 'node:crypto';
16 | import {readFile} from 'node:fs/promises';
17 |
18 | /**
19 | * Read and calculate the checksum of file located in `filepath` using the
20 | * specific hashing `algorithm`.
21 | * @param {string} filepath The full path of the file to be read.
22 | * @param {'sha256'|'sha512'} algorithm The hashing algorithm supported by node.
23 | * @returns {Promise} The checksum represented in hex string with lower
24 | * case letters (e.g. `123acf`); or `null` if any
25 | * errors are thrown (such as file not readable).
26 | */
27 | export async function getFileChecksum(filepath, algorithm) {
28 | if (!filepath || !algorithm) {
29 | throw new Error('filepath and algorithm are required');
30 | }
31 | try {
32 | const buffer = await readFile(filepath);
33 | const hasher = createHash(algorithm);
34 | hasher.update(buffer);
35 | return hasher.digest('hex');
36 | } catch {
37 | return null;
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/src/build/get_root_dir.mjs:
--------------------------------------------------------------------------------
1 | // Copyright 2023 The Outline Authors
2 | //
3 | // Licensed under the Apache License, Version 2.0 (the "License");
4 | // you may not use this file except in compliance with the License.
5 | // You may obtain a copy of the License at
6 | //
7 | // http://www.apache.org/licenses/LICENSE-2.0
8 | //
9 | // Unless required by applicable law or agreed to in writing, software
10 | // distributed under the License is distributed on an "AS IS" BASIS,
11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | // See the License for the specific language governing permissions and
13 | // limitations under the License.
14 |
15 | import {dirname, resolve} from 'node:path';
16 | import {fileURLToPath} from 'node:url';
17 |
18 | // WARNING: if you move this file, you MUST update this file path
19 | const ROOT_DIR = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
20 |
21 | export function getRootDir() {
22 | return ROOT_DIR;
23 | }
24 |
--------------------------------------------------------------------------------
/src/metrics_server/README.md:
--------------------------------------------------------------------------------
1 | # Outline Metrics Server
2 |
3 | The Outline Metrics Server is a [Google App Engine](https://cloud.google.com/appengine) project that writes feature and connections metrics to BigQuery, as reported by opted-in Outline servers.
4 |
5 | ## API
6 |
7 | ### Endpoints
8 |
9 | The metrics server deploys two services: `dev`, used for development testing and debugging; and `prod`, used for production metrics. The `dev` environment is deployed to `https://dev.metrics.getoutline.org`; the `prod` environment is deployed to `https://prod.metrics.getoutline.org`. Each environment posts metrics to its own BigQuery dataset (see `config_[dev|prod].json`).
10 |
11 | ### URLs
12 |
13 | The metrics server supports two URL paths:
14 |
15 | - `POST /connections`: report server data usage broken down by user.
16 |
17 | ```
18 | {
19 | serverId: string,
20 | startUtcMs: number,
21 | endUtcMs: number,
22 | userReports: [{
23 | countries: string[],
24 | asn: number,
25 | bytesTransferred: number,
26 | tunnelTimeSec: number,
27 | }]
28 | }
29 | ```
30 |
31 | - `POST /features`: report feature usage.
32 |
33 | ```
34 | {
35 | serverId: string,
36 | serverVersion: string,
37 | timestampUtcMs: number,
38 | dataLimit: {
39 | enabled: boolean
40 | perKeyLimitCount: number
41 | }
42 | }
43 | ```
44 |
45 | ## Requirements
46 |
47 | - [Google Cloud SDK](https://cloud.google.com/sdk/)
48 |
49 | ## Build
50 |
51 | ```sh
52 | task metrics_server:build
53 | ```
54 |
55 | ## Run
56 |
57 | Run a local development metrics server:
58 |
59 | ```sh
60 | task metrics_server:start
61 | ```
62 |
63 | ## Deploy
64 |
65 | - Authenticate with `gcloud`:
66 | ```sh
67 | gcloud auth login
68 | ```
69 | - To deploy to dev:
70 | ```sh
71 | task metrics_server:deploy:dev
72 | ```
73 | - To deploy to prod:
74 | ```sh
75 | task metrics_server:deploy:prod
76 | ```
77 | Once deployed, you will need to manually migrate all traffic to the new version via the Google Cloud console.
78 |
79 | ## Test
80 |
81 | - Unit test
82 | ```sh
83 | task metrics_server:test
84 | ```
85 | - Integration test
86 | ```sh
87 | task metrics_server:integration_test
88 | ```
89 |
--------------------------------------------------------------------------------
/src/metrics_server/Taskfile.yml:
--------------------------------------------------------------------------------
1 | # Copyright 2024 The Outline Authors
2 | #
3 | # Licensed under the Apache License, Version 2.0 (the "License");
4 | # you may not use this file except in compliance with the License.
5 | # You may obtain a copy of the License at
6 | #
7 | # http://www.apache.org/licenses/LICENSE-2.0
8 | #
9 | # Unless required by applicable law or agreed to in writing, software
10 | # distributed under the License is distributed on an "AS IS" BASIS,
11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | # See the License for the specific language governing permissions and
13 | # limitations under the License.
14 |
15 | version: '3'
16 |
17 | requires:
18 | vars: [OUTPUT_BASE]
19 |
20 | tasks:
21 | clean:
22 | desc: Clean metrics server output
23 | cmds:
24 | - rm -rf "{{.OUTPUT_BASE}}"
25 |
26 | build:
27 | desc: Build the metrics server
28 | vars:
29 | BUILD_MODE: '{{.BUILD_MODE | default "dev"}}'
30 | TARGET_DIR: &default-target-dir '{{joinPath .OUTPUT_BASE .BUILD_MODE}}'
31 | cmds:
32 | - rm -rf '{{.TARGET_DIR}}'
33 | - npx tsc --project '{{.TASKFILE_DIR}}' --outDir '{{.TARGET_DIR}}'
34 | - cp '{{joinPath .TASKFILE_DIR "package.json"}}' '{{.TARGET_DIR}}'
35 | - cp '{{joinPath .USER_WORKING_DIR "package-lock.json"}}' '{{.TARGET_DIR}}'
36 | - cp '{{.TASKFILE_DIR}}/app_{{.BUILD_MODE}}.yaml' '{{.TARGET_DIR}}/app.yaml'
37 | - cp '{{.TASKFILE_DIR}}/config_{{.BUILD_MODE}}.json' '{{.TARGET_DIR}}/config.json'
38 |
39 | deploy:dev:
40 | desc: Deploy the development metrics server
41 | vars:
42 | BUILD_MODE: "dev"
43 | TARGET_DIR: *default-target-dir
44 | deps: [{task: build, vars: {BUILD_MODE: "{{.BUILD_MODE}}", TARGET_DIR: "{{.TARGET_DIR}}"}}]
45 | cmds:
46 | - gcloud app deploy '{{.TASKFILE_DIR}}/dispatch.yaml' '{{.TARGET_DIR}}' --project uproxysite --verbosity info --promote --stop-previous-version
47 |
48 | deploy:prod:
49 | desc: Deploy the production metrics server
50 | vars:
51 | BUILD_MODE: "prod"
52 | TARGET_DIR: *default-target-dir
53 | deps: [{task: build, vars: {BUILD_MODE: "{{.BUILD_MODE}}", TARGET_DIR: "{{.TARGET_DIR}}"}}]
54 | cmds:
55 | - gcloud app deploy '{{.TASKFILE_DIR}}/dispatch.yaml' '{{joinPath .OUTPUT_BASE "prod"}}' --project uproxysite --verbosity info --no-promote --no-stop-previous-version
56 |
57 | start:
58 | desc: Start the metrics server locally
59 | vars:
60 | BUILD_MODE: '{{.BUILD_MODE | default "dev"}}'
61 | TARGET_DIR: *default-target-dir
62 | deps: [{task: build, vars: {BUILD_MODE: "{{.BUILD_MODE}}", TARGET_DIR: "{{.TARGET_DIR}}"}}]
63 | cmds:
64 | - node '{{joinPath .TARGET_DIR "index.js"}}'
65 |
66 | integration_test:
67 | desc: Test the deployed dev metrics server
68 | cmds:
69 | - '{{.TASKFILE_DIR}}/test_integration.sh'
70 |
71 | test:
72 | desc: Run the unit tests for the metrics server
73 | vars:
74 | TEST_DIR:
75 | sh: "mktemp -d"
76 | cmds:
77 | - defer: rm -rf "{{.TEST_DIR}}"
78 | - npx tsc -p '{{.TASKFILE_DIR}}' --outDir '{{.TEST_DIR}}'
79 | - npx jasmine '{{.TEST_DIR}}/**/*.spec.js'
80 |
--------------------------------------------------------------------------------
/src/metrics_server/app_dev.yaml:
--------------------------------------------------------------------------------
1 | # Copyright 2024 The Outline Authors
2 | #
3 | # Licensed under the Apache License, Version 2.0 (the "License");
4 | # you may not use this file except in compliance with the License.
5 | # You may obtain a copy of the License at
6 | #
7 | # http://www.apache.org/licenses/LICENSE-2.0
8 | #
9 | # Unless required by applicable law or agreed to in writing, software
10 | # distributed under the License is distributed on an "AS IS" BASIS,
11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | # See the License for the specific language governing permissions and
13 | # limitations under the License.
14 |
15 | runtime: nodejs18
16 | service: dev
17 | handlers:
18 | - url: /.*
19 | script: auto
20 | secure: always
21 | redirect_http_response_code: 307
22 |
--------------------------------------------------------------------------------
/src/metrics_server/app_prod.yaml:
--------------------------------------------------------------------------------
1 | # Copyright 2024 The Outline Authors
2 | #
3 | # Licensed under the Apache License, Version 2.0 (the "License");
4 | # you may not use this file except in compliance with the License.
5 | # You may obtain a copy of the License at
6 | #
7 | # http://www.apache.org/licenses/LICENSE-2.0
8 | #
9 | # Unless required by applicable law or agreed to in writing, software
10 | # distributed under the License is distributed on an "AS IS" BASIS,
11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | # See the License for the specific language governing permissions and
13 | # limitations under the License.
14 |
15 | runtime: nodejs18
16 | service: prod
17 | handlers:
18 | - url: /.*
19 | script: auto
20 | secure: always
21 | redirect_http_response_code: 307
22 |
--------------------------------------------------------------------------------
/src/metrics_server/config_dev.json:
--------------------------------------------------------------------------------
1 | {
2 | "datasetName": "uproxy_metrics_dev",
3 | "connectionMetricsTableName": "connections_v1",
4 | "featureMetricsTableName": "feature_metrics"
5 | }
6 |
--------------------------------------------------------------------------------
/src/metrics_server/config_prod.json:
--------------------------------------------------------------------------------
1 | {
2 | "datasetName": "uproxy_metrics",
3 | "connectionMetricsTableName": "connections_v1",
4 | "featureMetricsTableName": "feature_metrics"
5 | }
6 |
--------------------------------------------------------------------------------
/src/metrics_server/connection_metrics.spec.ts:
--------------------------------------------------------------------------------
1 | // Copyright 2020 The Outline Authors
2 | //
3 | // Licensed under the Apache License, Version 2.0 (the "License");
4 | // you may not use this file except in compliance with the License.
5 | // You may obtain a copy of the License at
6 | //
7 | // http://www.apache.org/licenses/LICENSE-2.0
8 | //
9 | // Unless required by applicable law or agreed to in writing, software
10 | // distributed under the License is distributed on an "AS IS" BASIS,
11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | // See the License for the specific language governing permissions and
13 | // limitations under the License.
14 |
15 | import {
16 | ConnectionRow,
17 | isValidConnectionMetricsReport,
18 | postConnectionMetrics,
19 | } from './connection_metrics';
20 | import {InsertableTable} from './infrastructure/table';
21 | import {HourlyConnectionMetricsReport, HourlyUserConnectionMetricsReport} from './model';
22 |
23 | const VALID_USER_REPORT: HourlyUserConnectionMetricsReport = {
24 | countries: ['US'],
25 | bytesTransferred: 123,
26 | tunnelTimeSec: 789,
27 | };
28 |
29 | const VALID_USER_REPORT2: HourlyUserConnectionMetricsReport = {
30 | countries: ['UK'],
31 | asn: 54321,
32 | bytesTransferred: 456,
33 | };
34 |
35 | /*
36 | * Legacy access key user reports to ensure backwards compatibility with servers not
37 | * synced past https://github.com/Jigsaw-Code/outline-server/pull/1529).
38 | */
39 | const LEGACY_PER_KEY_USER_REPORT: HourlyUserConnectionMetricsReport = {
40 | userId: 'foo',
41 | bytesTransferred: 123,
42 | };
43 |
44 | /*
45 | * Legacy multiple countries user reports to ensure backwards compatibility with servers
46 | * not synced past https://github.com/Jigsaw-Code/outline-server/pull/1242.
47 | */
48 | const LEGACY_PER_LOCATION_USER_REPORT: HourlyUserConnectionMetricsReport = {
49 | userId: 'foobar',
50 | countries: ['US', 'UK'],
51 | bytesTransferred: 123,
52 | };
53 |
54 | const VALID_REPORT: HourlyConnectionMetricsReport = {
55 | serverId: 'id',
56 | startUtcMs: 1,
57 | endUtcMs: 2,
58 | userReports: [
59 | structuredClone(VALID_USER_REPORT),
60 | structuredClone(VALID_USER_REPORT2),
61 | structuredClone(LEGACY_PER_LOCATION_USER_REPORT),
62 | ],
63 | };
64 |
65 | const LEGACY_REPORT: HourlyConnectionMetricsReport = {
66 | serverId: 'legacy-id',
67 | startUtcMs: 3,
68 | endUtcMs: 4,
69 | userReports: [structuredClone(LEGACY_PER_KEY_USER_REPORT)],
70 | };
71 |
72 | class FakeConnectionsTable implements InsertableTable {
73 | rows: ConnectionRow[] | undefined;
74 |
75 | async insert(rows: ConnectionRow[]) {
76 | this.rows = rows;
77 | }
78 | }
79 |
80 | describe('postConnectionMetrics', () => {
81 | it('correctly inserts connection metrics rows', async () => {
82 | const table = new FakeConnectionsTable();
83 |
84 | await postConnectionMetrics(table, VALID_REPORT);
85 |
86 | const rows: ConnectionRow[] = [
87 | {
88 | serverId: VALID_REPORT.serverId,
89 | startTimestamp: new Date(VALID_REPORT.startUtcMs).toISOString(),
90 | endTimestamp: new Date(VALID_REPORT.endUtcMs).toISOString(),
91 | bytesTransferred: VALID_USER_REPORT.bytesTransferred,
92 | tunnelTimeSec: VALID_USER_REPORT.tunnelTimeSec,
93 | countries: VALID_USER_REPORT.countries,
94 | asn: undefined,
95 | },
96 | {
97 | serverId: VALID_REPORT.serverId,
98 | startTimestamp: new Date(VALID_REPORT.startUtcMs).toISOString(),
99 | endTimestamp: new Date(VALID_REPORT.endUtcMs).toISOString(),
100 | bytesTransferred: VALID_USER_REPORT2.bytesTransferred,
101 | tunnelTimeSec: VALID_USER_REPORT2.tunnelTimeSec,
102 | countries: VALID_USER_REPORT2.countries,
103 | asn: VALID_USER_REPORT2.asn!,
104 | },
105 | {
106 | serverId: VALID_REPORT.serverId,
107 | startTimestamp: new Date(VALID_REPORT.startUtcMs).toISOString(),
108 | endTimestamp: new Date(VALID_REPORT.endUtcMs).toISOString(),
109 | bytesTransferred: LEGACY_PER_LOCATION_USER_REPORT.bytesTransferred,
110 | tunnelTimeSec: LEGACY_PER_LOCATION_USER_REPORT.tunnelTimeSec,
111 | countries: LEGACY_PER_LOCATION_USER_REPORT.countries,
112 | asn: undefined,
113 | },
114 | ];
115 | expect(table.rows).toEqual(rows);
116 | });
117 | it('correctly drops legacy connection metrics', async () => {
118 | const table = new FakeConnectionsTable();
119 |
120 | await postConnectionMetrics(table, LEGACY_REPORT);
121 |
122 | expect(table.rows).toEqual([]);
123 | });
124 | });
125 |
126 | describe('isValidConnectionMetricsReport', () => {
127 | it('returns true for valid report', () => {
128 | const report = structuredClone(VALID_REPORT);
129 | expect(isValidConnectionMetricsReport(report)).toBeTrue();
130 | });
131 | it('returns true for legacy report', () => {
132 | const report = structuredClone(LEGACY_REPORT);
133 | expect(isValidConnectionMetricsReport(report)).toBeTrue();
134 | });
135 | it('returns false for missing report', () => {
136 | expect(isValidConnectionMetricsReport(undefined)).toBeFalse();
137 | });
138 | it('returns false for inconsistent timestamp values', () => {
139 | const report = structuredClone(VALID_REPORT);
140 | // startUtcMs > endUtcMs
141 | report.startUtcMs = 999;
142 | report.endUtcMs = 1;
143 | expect(isValidConnectionMetricsReport(report)).toBeFalse();
144 | });
145 | it('returns false for out-of-bounds transferred bytes', () => {
146 | const report = structuredClone(VALID_REPORT);
147 | report.userReports[0].bytesTransferred = -123;
148 | expect(isValidConnectionMetricsReport(report)).toBeFalse();
149 |
150 | // 2TB is above the server capacity
151 | report.userReports[0].bytesTransferred = 2 * Math.pow(2, 40);
152 | expect(isValidConnectionMetricsReport(report)).toBeFalse();
153 | });
154 | it('returns false for out-of-bounds tunnel time', () => {
155 | const report = structuredClone(VALID_REPORT);
156 | report.userReports[0].tunnelTimeSec = -123;
157 | expect(isValidConnectionMetricsReport(report)).toBeFalse();
158 | });
159 | it('returns false for missing user reports', () => {
160 | const report: Partial = structuredClone(VALID_REPORT);
161 | delete report['userReports'];
162 | expect(isValidConnectionMetricsReport(report)).toBeFalse();
163 | });
164 | it('returns false for empty user reports', () => {
165 | const report = structuredClone(VALID_REPORT);
166 | report.userReports = [];
167 | expect(isValidConnectionMetricsReport(report)).toBeFalse();
168 | });
169 | it('returns false for missing `serverId`', () => {
170 | const report: Partial = structuredClone(VALID_REPORT);
171 | delete report['serverId'];
172 | expect(isValidConnectionMetricsReport(report)).toBeFalse();
173 | });
174 | it('returns false for missing `startUtcMs`', () => {
175 | const report: Partial = structuredClone(VALID_REPORT);
176 | delete report['startUtcMs'];
177 | expect(isValidConnectionMetricsReport(report)).toBeFalse();
178 | });
179 | it('returns false for missing `endUtcMs`', () => {
180 | const report: Partial = structuredClone(VALID_REPORT);
181 | delete report['endUtcMs'];
182 | expect(isValidConnectionMetricsReport(report)).toBeFalse();
183 | });
184 | it('returns false for missing user report field `bytesTransferred`', () => {
185 | const report = structuredClone(VALID_REPORT);
186 | const userReport: Partial =
187 | structuredClone(VALID_USER_REPORT);
188 | delete userReport['bytesTransferred'];
189 | report.userReports[0] = userReport as HourlyUserConnectionMetricsReport;
190 | expect(isValidConnectionMetricsReport(report)).toBeFalse();
191 | });
192 | it('returns false for user report field types that is not `HourlyUserConnectionMetricsReport`', () => {
193 | const report = structuredClone(VALID_REPORT);
194 | report.userReports = [1, 2, 3] as unknown as HourlyUserConnectionMetricsReport[];
195 | expect(isValidConnectionMetricsReport(report)).toBeFalse();
196 | });
197 | it('returns false for `serverId` field type that is not a string', () => {
198 | const report = structuredClone(VALID_REPORT);
199 | report.serverId = 987 as unknown as string;
200 | expect(isValidConnectionMetricsReport(report)).toBeFalse();
201 | });
202 | it('returns false for `startUtcMs` field type that is not a number', () => {
203 | const report = structuredClone(VALID_REPORT);
204 | report.startUtcMs = '100' as unknown as number;
205 | expect(isValidConnectionMetricsReport(report)).toBeFalse();
206 | });
207 | it('returns false for `endUtcMs` field type that is not a number', () => {
208 | const report = structuredClone(VALID_REPORT);
209 | report.endUtcMs = '100' as unknown as number;
210 | expect(isValidConnectionMetricsReport(report)).toBeFalse();
211 | });
212 | it('returns false for `userId` field type that is not a string', () => {
213 | const report = structuredClone(VALID_REPORT);
214 | report.userReports[0].userId = 1234 as unknown as string;
215 | expect(isValidConnectionMetricsReport(report)).toBeFalse();
216 | });
217 | it('returns false for `countries` field type that is not an array', () => {
218 | const report = structuredClone(VALID_REPORT);
219 | report.userReports[0].countries = 'US' as unknown as string[];
220 | expect(isValidConnectionMetricsReport(report)).toBeFalse();
221 | });
222 | it('returns false for `countries` array items that are not strings', () => {
223 | const report = structuredClone(VALID_REPORT);
224 | report.userReports[0].countries = [1, 2, 3] as unknown as string[];
225 | expect(isValidConnectionMetricsReport(report)).toBeFalse();
226 | });
227 | it('returns false for `asn` field type that is not a number', () => {
228 | const report = structuredClone(VALID_REPORT);
229 | report.userReports[0].asn = '123' as unknown as number;
230 | expect(isValidConnectionMetricsReport(report)).toBeFalse();
231 | });
232 | it('returns false for `bytesTransferred` field type that is not a number', () => {
233 | const report = structuredClone(VALID_REPORT);
234 | report.userReports[0].bytesTransferred = '1234' as unknown as number;
235 | expect(isValidConnectionMetricsReport(report)).toBeFalse();
236 | });
237 | it('returns false for `tunnelTimeSec` field type that is not a number', () => {
238 | const report = structuredClone(VALID_REPORT);
239 | report.userReports[0].tunnelTimeSec = '789' as unknown as number;
240 | expect(isValidConnectionMetricsReport(report)).toBeFalse();
241 | });
242 | });
243 |
--------------------------------------------------------------------------------
/src/metrics_server/connection_metrics.ts:
--------------------------------------------------------------------------------
1 | // Copyright 2020 The Outline Authors
2 | //
3 | // Licensed under the Apache License, Version 2.0 (the "License");
4 | // you may not use this file except in compliance with the License.
5 | // You may obtain a copy of the License at
6 | //
7 | // http://www.apache.org/licenses/LICENSE-2.0
8 | //
9 | // Unless required by applicable law or agreed to in writing, software
10 | // distributed under the License is distributed on an "AS IS" BASIS,
11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | // See the License for the specific language governing permissions and
13 | // limitations under the License.
14 |
15 | import {Table} from '@google-cloud/bigquery';
16 | import {InsertableTable} from './infrastructure/table';
17 | import {
18 | HourlyConnectionMetricsReport,
19 | HourlyUserConnectionMetricsReport,
20 | HourlyUserConnectionMetricsReportByLocation,
21 | } from './model';
22 |
23 | const TERABYTE = Math.pow(2, 40);
24 |
25 | export interface ConnectionRow {
26 | serverId: string;
27 | startTimestamp: string; // ISO formatted string.
28 | endTimestamp: string; // ISO formatted string.
29 | bytesTransferred: number;
30 | tunnelTimeSec?: number;
31 | countries?: string[];
32 | asn?: number;
33 | }
34 |
35 | export class BigQueryConnectionsTable implements InsertableTable {
36 | constructor(private bigqueryTable: Table) {}
37 |
38 | async insert(rows: ConnectionRow[]): Promise {
39 | await this.bigqueryTable.insert(rows);
40 | }
41 | }
42 |
43 | export function postConnectionMetrics(
44 | table: InsertableTable,
45 | report: HourlyConnectionMetricsReport
46 | ): Promise {
47 | return table.insert(getConnectionRowsFromReport(report));
48 | }
49 |
50 | function getConnectionRowsFromReport(report: HourlyConnectionMetricsReport): ConnectionRow[] {
51 | const startTimestampStr = new Date(report.startUtcMs).toISOString();
52 | const endTimestampStr = new Date(report.endUtcMs).toISOString();
53 | const rows = [];
54 | for (const userReport of report.userReports) {
55 | // User reports come in 2 flavors: "per location" and "per key". We no longer store the
56 | // "per key" reports.
57 | if (isPerLocationUserReport(userReport)) {
58 | rows.push({
59 | serverId: report.serverId,
60 | startTimestamp: startTimestampStr,
61 | endTimestamp: endTimestampStr,
62 | bytesTransferred: userReport.bytesTransferred,
63 | tunnelTimeSec: userReport.tunnelTimeSec || undefined,
64 | countries: userReport.countries,
65 | asn: userReport.asn || undefined,
66 | });
67 | }
68 | }
69 | return rows;
70 | }
71 |
72 | function isPerLocationUserReport(
73 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
74 | userReport: HourlyUserConnectionMetricsReport
75 | ): userReport is HourlyUserConnectionMetricsReportByLocation {
76 | return 'countries' in userReport;
77 | }
78 |
79 | // Returns true iff testObject contains a valid HourlyConnectionMetricsReport.
80 | export function isValidConnectionMetricsReport(
81 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
82 | testObject: any
83 | ): testObject is HourlyConnectionMetricsReport {
84 | if (!testObject) {
85 | console.debug('Missing test object');
86 | return false;
87 | }
88 |
89 | const requiredConnectionMetricsFields = ['serverId', 'startUtcMs', 'endUtcMs', 'userReports'];
90 | for (const fieldName of requiredConnectionMetricsFields) {
91 | if (!testObject[fieldName]) {
92 | console.debug(`Missing required field \`${fieldName}\``);
93 | return false;
94 | }
95 | }
96 |
97 | if (typeof testObject.serverId !== 'string') {
98 | console.debug('Invalid `serverId`');
99 | return false;
100 | }
101 |
102 | if (
103 | typeof testObject.startUtcMs !== 'number' ||
104 | typeof testObject.endUtcMs !== 'number' ||
105 | testObject.startUtcMs >= testObject.endUtcMs
106 | ) {
107 | console.debug('Invalid `startUtcMs` and/or `endUtcMs`');
108 | return false;
109 | }
110 |
111 | if (testObject.userReports.length === 0) {
112 | console.debug('At least 1 user report must be provided');
113 | return false;
114 | }
115 |
116 | for (const userReport of testObject.userReports) {
117 | if (userReport.userId && typeof userReport.userId !== 'string') {
118 | console.debug('Invalid `serverId`');
119 | return false;
120 | }
121 |
122 | // We used to set a limit of 1TB per access key, then per location. We later
123 | // realized that a server may use a single key, or all the traffic may come
124 | // from a single location.
125 | // However, as we report hourly, it's unlikely we hit 1TB, so we keep the
126 | // check for now to try and prevent malicious reports.
127 | if (
128 | typeof userReport.bytesTransferred !== 'number' ||
129 | userReport.bytesTransferred < 0 ||
130 | userReport.bytesTransferred > TERABYTE
131 | ) {
132 | console.debug('Invalid `bytesTransferred`');
133 | return false;
134 | }
135 |
136 | if (
137 | userReport.tunnelTimeSec &&
138 | (typeof userReport.tunnelTimeSec !== 'number' || userReport.tunnelTimeSec < 0)
139 | ) {
140 | console.debug('Invalid `tunnelTimeSec`');
141 | return false;
142 | }
143 |
144 | if (userReport.countries) {
145 | if (!Array.isArray(userReport.countries)) {
146 | console.debug('Invalid `countries`');
147 | return false;
148 | }
149 | for (const country of userReport.countries) {
150 | if (typeof country !== 'string') {
151 | console.debug('Invalid `countries`');
152 | return false;
153 | }
154 | }
155 | }
156 |
157 | if (userReport.asn && typeof userReport.asn !== 'number') {
158 | console.debug('Invalid `asn`');
159 | return false;
160 | }
161 | }
162 |
163 | // Request is a valid HourlyConnectionMetricsReport.
164 | return true;
165 | }
166 |
--------------------------------------------------------------------------------
/src/metrics_server/dispatch.yaml:
--------------------------------------------------------------------------------
1 | # Copyright 2024 The Outline Authors
2 | #
3 | # Licensed under the Apache License, Version 2.0 (the "License");
4 | # you may not use this file except in compliance with the License.
5 | # You may obtain a copy of the License at
6 | #
7 | # http://www.apache.org/licenses/LICENSE-2.0
8 | #
9 | # Unless required by applicable law or agreed to in writing, software
10 | # distributed under the License is distributed on an "AS IS" BASIS,
11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | # See the License for the specific language governing permissions and
13 | # limitations under the License.
14 |
15 | dispatch:
16 | - url: "prod.metrics.getoutline.org/*"
17 | service: prod
18 | - url: "dev.metrics.getoutline.org/*"
19 | service: dev
20 |
--------------------------------------------------------------------------------
/src/metrics_server/feature_metrics.spec.ts:
--------------------------------------------------------------------------------
1 | // Copyright 2020 The Outline Authors
2 | //
3 | // Licensed under the Apache License, Version 2.0 (the "License");
4 | // you may not use this file except in compliance with the License.
5 | // You may obtain a copy of the License at
6 | //
7 | // http://www.apache.org/licenses/LICENSE-2.0
8 | //
9 | // Unless required by applicable law or agreed to in writing, software
10 | // distributed under the License is distributed on an "AS IS" BASIS,
11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | // See the License for the specific language governing permissions and
13 | // limitations under the License.
14 |
15 | import {FeatureRow, isValidFeatureMetricsReport, postFeatureMetrics} from './feature_metrics';
16 | import {InsertableTable} from './infrastructure/table';
17 | import {DailyFeatureMetricsReport} from './model';
18 |
19 | class FakeFeaturesTable implements InsertableTable {
20 | rows: FeatureRow[] | undefined;
21 |
22 | async insert(rows: FeatureRow[]) {
23 | this.rows = rows;
24 | }
25 | }
26 |
27 | describe('postFeatureMetrics', () => {
28 | it('correctly inserts feature metrics rows', async () => {
29 | const table = new FakeFeaturesTable();
30 | const report: DailyFeatureMetricsReport = {
31 | serverId: 'id',
32 | serverVersion: '0.0.0',
33 | timestampUtcMs: 123456,
34 | dataLimit: {enabled: false},
35 | };
36 | await postFeatureMetrics(table, report);
37 | const rows: FeatureRow[] = [
38 | {
39 | serverId: report.serverId,
40 | serverVersion: report.serverVersion,
41 | timestamp: new Date(report.timestampUtcMs).toISOString(),
42 | dataLimit: report.dataLimit,
43 | },
44 | ];
45 | expect(table.rows).toEqual(rows);
46 | });
47 | });
48 |
49 | describe('isValidFeatureMetricsReport', () => {
50 | it('returns true for valid report', () => {
51 | const report = {
52 | serverId: 'id',
53 | serverVersion: '0.0.0',
54 | timestampUtcMs: 123456,
55 | dataLimit: {enabled: true},
56 | };
57 | expect(isValidFeatureMetricsReport(report)).toBeTruthy();
58 | });
59 | it('returns true for valid report with per-key data limit count', () => {
60 | const report = {
61 | serverId: 'id',
62 | serverVersion: '0.0.0',
63 | timestampUtcMs: 123456,
64 | dataLimit: {enabled: true, perKeyLimitCount: 1},
65 | };
66 | expect(isValidFeatureMetricsReport(report)).toBeTruthy();
67 | });
68 | it('returns false for report with negative per-key data limit count', () => {
69 | const report = {
70 | serverId: 'id',
71 | serverVersion: '0.0.0',
72 | timestampUtcMs: 123456,
73 | dataLimit: {enabled: true, perKeyLimitCount: -1},
74 | };
75 | expect(isValidFeatureMetricsReport(report)).toBeFalsy();
76 | });
77 | it('returns false for missing report', () => {
78 | expect(isValidFeatureMetricsReport(undefined)).toBeFalsy();
79 | });
80 | it('returns false for incorrect report field types', () => {
81 | const invalidReport = {
82 | serverId: 1234, // Should be a string
83 | serverVersion: '0.0.0',
84 | timestampUtcMs: 123456,
85 | dataLimit: {enabled: true},
86 | };
87 | expect(isValidFeatureMetricsReport(invalidReport)).toBeFalsy();
88 |
89 | const invalidReport2 = {
90 | serverId: 'id',
91 | serverVersion: 1010, // Should be a string
92 | timestampUtcMs: 123456,
93 | dataLimit: {enabled: true},
94 | };
95 | expect(isValidFeatureMetricsReport(invalidReport2)).toBeFalsy();
96 |
97 | const invalidReport3 = {
98 | serverId: 'id',
99 | serverVersion: '0.0.0',
100 | timestampUtcMs: '123', // Should be a number
101 | dataLimit: {enabled: true},
102 | };
103 | expect(isValidFeatureMetricsReport(invalidReport3)).toBeFalsy();
104 |
105 | const invalidReport4 = {
106 | serverId: 'id',
107 | serverVersion: '0.0.0',
108 | timestampUtcMs: 123456,
109 | dataLimit: 'enabled', // Should be `DailyDataLimitMetricsReport`
110 | };
111 | expect(isValidFeatureMetricsReport(invalidReport4)).toBeFalsy();
112 |
113 | const invalidReport5 = {
114 | serverId: 'id',
115 | serverVersion: '0.0.0',
116 | timestampUtcMs: 123456,
117 | dataLimit: {
118 | enabled: 'true', // Should be a boolean
119 | },
120 | };
121 | expect(isValidFeatureMetricsReport(invalidReport5)).toBeFalsy();
122 | });
123 | it('returns false for missing report fields', () => {
124 | const invalidReport = {
125 | // Missing `serverId`
126 | serverVersion: '0.0.0',
127 | timestampUtcMs: 123456,
128 | dataLimit: {enabled: true},
129 | };
130 | expect(isValidFeatureMetricsReport(invalidReport)).toBeFalsy();
131 |
132 | const invalidReport2 = {
133 | // Missing `serverVersion`
134 | serverId: 'id',
135 | timestampUtcMs: 123456,
136 | dataLimit: {enabled: true},
137 | };
138 | expect(isValidFeatureMetricsReport(invalidReport2)).toBeFalsy();
139 |
140 | const invalidReport3 = {
141 | // Missing `timestampUtcMs`
142 | serverId: 'id',
143 | serverVersion: '0.0.0',
144 | dataLimit: {enabled: true},
145 | };
146 | expect(isValidFeatureMetricsReport(invalidReport3)).toBeFalsy();
147 |
148 | const invalidReport4 = {
149 | // Missing `dataLimit`
150 | serverId: 'id',
151 | serverVersion: '0.0.0',
152 | timestampUtcMs: 123456,
153 | };
154 | expect(isValidFeatureMetricsReport(invalidReport4)).toBeFalsy();
155 |
156 | const invalidReport5 = {
157 | // Missing `dataLimit.enabled`
158 | serverId: 'id',
159 | serverVersion: '0.0.0',
160 | timestampUtcMs: 123456,
161 | dataLimit: {},
162 | };
163 | expect(isValidFeatureMetricsReport(invalidReport5)).toBeFalsy();
164 | });
165 | });
166 |
--------------------------------------------------------------------------------
/src/metrics_server/feature_metrics.ts:
--------------------------------------------------------------------------------
1 | // Copyright 2020 The Outline Authors
2 | //
3 | // Licensed under the Apache License, Version 2.0 (the "License");
4 | // you may not use this file except in compliance with the License.
5 | // You may obtain a copy of the License at
6 | //
7 | // http://www.apache.org/licenses/LICENSE-2.0
8 | //
9 | // Unless required by applicable law or agreed to in writing, software
10 | // distributed under the License is distributed on an "AS IS" BASIS,
11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | // See the License for the specific language governing permissions and
13 | // limitations under the License.
14 |
15 | import {Table} from '@google-cloud/bigquery';
16 |
17 | import {InsertableTable} from './infrastructure/table';
18 | import {DailyDataLimitMetricsReport, DailyFeatureMetricsReport} from './model';
19 |
20 | // Reflects the feature metrics BigQuery table schema.
21 | export interface FeatureRow {
22 | serverId: string;
23 | serverVersion: string;
24 | timestamp: string; // ISO formatted string
25 | dataLimit: DailyDataLimitMetricsReport;
26 | }
27 |
28 | export class BigQueryFeaturesTable implements InsertableTable {
29 | constructor(private bigqueryTable: Table) {}
30 |
31 | async insert(rows: FeatureRow | FeatureRow[]): Promise {
32 | await this.bigqueryTable.insert(rows);
33 | }
34 | }
35 |
36 | export async function postFeatureMetrics(
37 | table: InsertableTable,
38 | report: DailyFeatureMetricsReport
39 | ) {
40 | const featureRow: FeatureRow = {
41 | serverId: report.serverId,
42 | serverVersion: report.serverVersion,
43 | timestamp: new Date(report.timestampUtcMs).toISOString(),
44 | dataLimit: report.dataLimit,
45 | };
46 | return table.insert([featureRow]);
47 | }
48 |
49 | // Returns true iff `obj` contains a valid DailyFeatureMetricsReport.
50 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
51 | export function isValidFeatureMetricsReport(obj: any): obj is DailyFeatureMetricsReport {
52 | if (!obj) {
53 | return false;
54 | }
55 |
56 | // Check that all required fields are present.
57 | const requiredFeatureMetricsReportFields = [
58 | 'serverId',
59 | 'serverVersion',
60 | 'timestampUtcMs',
61 | 'dataLimit',
62 | ];
63 | for (const fieldName of requiredFeatureMetricsReportFields) {
64 | if (!obj[fieldName]) {
65 | return false;
66 | }
67 | }
68 |
69 | // Validate the report types are what we expect.
70 | if (
71 | typeof obj.serverId !== 'string' ||
72 | typeof obj.serverVersion !== 'string' ||
73 | typeof obj.timestampUtcMs !== 'number'
74 | ) {
75 | return false;
76 | }
77 |
78 | // Validate the server data limit feature
79 | if (typeof obj.dataLimit.enabled !== 'boolean') {
80 | return false;
81 | }
82 |
83 | // Validate the per-key data limit feature
84 | const perKeyLimitCount = obj.dataLimit.perKeyLimitCount;
85 | if (perKeyLimitCount === undefined) {
86 | return true;
87 | }
88 | if (typeof perKeyLimitCount === 'number') {
89 | return obj.dataLimit.perKeyLimitCount >= 0;
90 | }
91 | return false;
92 | }
93 |
--------------------------------------------------------------------------------
/src/metrics_server/index.ts:
--------------------------------------------------------------------------------
1 | // Copyright 2020 The Outline Authors
2 | //
3 | // Licensed under the Apache License, Version 2.0 (the "License");
4 | // you may not use this file except in compliance with the License.
5 | // You may obtain a copy of the License at
6 | //
7 | // http://www.apache.org/licenses/LICENSE-2.0
8 | //
9 | // Unless required by applicable law or agreed to in writing, software
10 | // distributed under the License is distributed on an "AS IS" BASIS,
11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | // See the License for the specific language governing permissions and
13 | // limitations under the License.
14 |
15 | import {BigQuery} from '@google-cloud/bigquery';
16 | import * as express from 'express';
17 | import * as fs from 'fs';
18 | import * as path from 'path';
19 |
20 | import * as connections from './connection_metrics';
21 | import * as features from './feature_metrics';
22 |
23 | interface Config {
24 | datasetName: string;
25 | connectionMetricsTableName: string;
26 | featureMetricsTableName: string;
27 | }
28 |
29 | function loadConfig(): Config {
30 | const configText = fs.readFileSync(path.join(__dirname, 'config.json'), {encoding: 'utf8'});
31 | return JSON.parse(configText);
32 | }
33 |
34 | const PORT = Number(process.env.PORT) || 8080;
35 | const config = loadConfig();
36 |
37 | const bigqueryDataset = new BigQuery({projectId: 'uproxysite'}).dataset(config.datasetName);
38 | const connectionsTable = new connections.BigQueryConnectionsTable(
39 | bigqueryDataset.table(config.connectionMetricsTableName)
40 | );
41 | const featuresTable = new features.BigQueryFeaturesTable(
42 | bigqueryDataset.table(config.featureMetricsTableName)
43 | );
44 |
45 | const app = express();
46 | // Parse the request body for content-type 'application/json'.
47 | app.use(express.json());
48 |
49 | // Accepts hourly connection metrics and inserts them into BigQuery.
50 | // Request body should contain an HourlyServerMetricsReport.
51 | app.post('/connections', async (req: express.Request, res: express.Response) => {
52 | try {
53 | if (!connections.isValidConnectionMetricsReport(req.body)) {
54 | res.status(400).send('Invalid request');
55 | return;
56 | }
57 | await connections.postConnectionMetrics(connectionsTable, req.body);
58 | res.status(200).send('OK');
59 | } catch (err) {
60 | res.status(500).send(`Error: ${err}`);
61 | }
62 | });
63 |
64 | // Accepts daily feature metrics and inserts them into BigQuery.
65 | // Request body should contain a `DailyFeatureMetricsReport`.
66 | app.post('/features', async (req: express.Request, res: express.Response) => {
67 | try {
68 | if (!features.isValidFeatureMetricsReport(req.body)) {
69 | res.status(400).send('Invalid request');
70 | return;
71 | }
72 | await features.postFeatureMetrics(featuresTable, req.body);
73 | res.status(200).send('OK');
74 | } catch (err) {
75 | res.status(500).send(`Error: ${err}`);
76 | }
77 | });
78 |
79 | app.listen(PORT, () => {
80 | console.log(`Metrics server listening on port ${PORT}`);
81 | });
82 |
--------------------------------------------------------------------------------
/src/metrics_server/infrastructure/table.ts:
--------------------------------------------------------------------------------
1 | // Copyright 2020 The Outline Authors
2 | //
3 | // Licensed under the Apache License, Version 2.0 (the "License");
4 | // you may not use this file except in compliance with the License.
5 | // You may obtain a copy of the License at
6 | //
7 | // http://www.apache.org/licenses/LICENSE-2.0
8 | //
9 | // Unless required by applicable law or agreed to in writing, software
10 | // distributed under the License is distributed on an "AS IS" BASIS,
11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | // See the License for the specific language governing permissions and
13 | // limitations under the License.
14 |
15 | // Generic table interface that supports row insertion.
16 | export interface InsertableTable {
17 | insert(rows: T[]): Promise;
18 | }
19 |
--------------------------------------------------------------------------------
/src/metrics_server/model.ts:
--------------------------------------------------------------------------------
1 | // Copyright 2020 The Outline Authors
2 | //
3 | // Licensed under the Apache License, Version 2.0 (the "License");
4 | // you may not use this file except in compliance with the License.
5 | // You may obtain a copy of the License at
6 | //
7 | // http://www.apache.org/licenses/LICENSE-2.0
8 | //
9 | // Unless required by applicable law or agreed to in writing, software
10 | // distributed under the License is distributed on an "AS IS" BASIS,
11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | // See the License for the specific language governing permissions and
13 | // limitations under the License.
14 |
15 | // NOTE: These interfaces are mirrored in in src/shadowbox/server/metrics.ts
16 | // Find a way to share them between shadowbox and metrics_server.
17 | export interface HourlyConnectionMetricsReport {
18 | serverId: string;
19 | startUtcMs: number;
20 | endUtcMs: number;
21 | userReports: HourlyUserConnectionMetricsReport[];
22 | }
23 |
24 | export interface HourlyUserConnectionMetricsReport {
25 | userId?: string;
26 | countries?: string[];
27 | asn?: number;
28 | bytesTransferred: number;
29 | tunnelTimeSec?: number;
30 | }
31 |
32 | export interface HourlyUserConnectionMetricsReportByLocation
33 | extends Omit {
34 | countries: string[];
35 | }
36 |
37 | export interface DailyFeatureMetricsReport {
38 | serverId: string;
39 | serverVersion: string;
40 | timestampUtcMs: number;
41 | dataLimit: DailyDataLimitMetricsReport;
42 | }
43 |
44 | export interface DailyDataLimitMetricsReport {
45 | enabled: boolean;
46 | perKeyLimitCount?: number;
47 | }
48 |
--------------------------------------------------------------------------------
/src/metrics_server/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "outline-metrics-server",
3 | "private": true,
4 | "version": "1.0.1",
5 | "description": "Outline metrics server",
6 | "author": "Outline",
7 | "license": "Apache",
8 | "__COMMENTS__": [
9 | "@google-cloud/storage here only to help Typescript code using @google-cloud/bigquery compile"
10 | ],
11 | "dependencies": {
12 | "@google-cloud/bigquery": "^5.12.0",
13 | "express": "^4.17.1"
14 | },
15 | "devDependencies": {
16 | "@google-cloud/storage": "^5.19.4",
17 | "@types/express": "^4.17.12"
18 | },
19 | "scripts": {
20 | "start": "node ./index.js"
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/src/metrics_server/test_integration.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash -eu
2 | #
3 | # Copyright 2020 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.
16 |
17 | # Metrics server integration test. Posts metrics to the development environment and queries BigQuery
18 | # to ensure the rows have been inserted to the features and connections tables.
19 | readonly BIGQUERY_PROJECT='uproxysite'
20 | readonly BIGQUERY_DATASET='uproxy_metrics_dev'
21 | readonly CONNECTIONS_TABLE='connections_v1'
22 | readonly FEATURES_TABLE='feature_metrics'
23 |
24 | readonly METRICS_URL='https://dev.metrics.getoutline.org'
25 |
26 | TMPDIR="$(mktemp -d)"
27 | readonly TMPDIR
28 | readonly CONNECTIONS_REQUEST="${TMPDIR}/connections.json"
29 | readonly CONNECTIONS_RESPONSE="${TMPDIR}/connections_res.json"
30 | readonly CONNECTIONS_EXPECTED_RESPONSE="${TMPDIR}/connections_expected_res.json"
31 | readonly FEATURES_REQUEST="${TMPDIR}/features_req.json"
32 | readonly FEATURES_RESPONSE="${TMPDIR}/features_res.json"
33 | readonly FEATURES_EXPECTED_RESPONSE="${TMPDIR}/features_expected_res.json"
34 |
35 | TIMESTAMP="$(date +%s%3N)"
36 | SERVER_ID="$(uuidgen)"
37 | SERVER_VERSION="$(uuidgen)"
38 | readonly TIMESTAMP SERVER_ID SERVER_VERSION
39 | # BYTES_TRANSFERRED2 < BYTES_TRANSFERRED1 so we can order the records before comparing them.
40 | BYTES_TRANSFERRED1=$((2 + RANDOM % 100))
41 | BYTES_TRANSFERRED2=$((BYTES_TRANSFERRED1 - 1))
42 | TUNNEL_TIME=$((RANDOM))
43 | PER_KEY_LIMIT_COUNT=$((RANDOM))
44 | declare -ir BYTES_TRANSFERRED1 BYTES_TRANSFERRED2 TUNNEL_TIME PER_KEY_LIMIT_COUNT
45 |
46 | echo "Using tmp directory ${TMPDIR}"
47 |
48 | # Write the request data to temporary files.
49 | cat << EOF > "${CONNECTIONS_REQUEST}"
50 | {
51 | "serverId": "${SERVER_ID}",
52 | "startUtcMs": ${TIMESTAMP},
53 | "endUtcMs": $((TIMESTAMP+1)),
54 | "userReports": [{
55 | "bytesTransferred": ${BYTES_TRANSFERRED1},
56 | "tunnelTimeSec": ${TUNNEL_TIME},
57 | "countries": ["US", "NL"]
58 | }, {
59 | "bytesTransferred": ${BYTES_TRANSFERRED2},
60 | "countries": ["UK"],
61 | "asn": 123
62 | }]
63 | }
64 | EOF
65 | cat << EOF > "${FEATURES_REQUEST}"
66 | {
67 | "serverId": "${SERVER_ID}",
68 | "serverVersion": "${SERVER_VERSION}",
69 | "timestampUtcMs": ${TIMESTAMP},
70 | "dataLimit": {
71 | "enabled": false,
72 | "perKeyLimitCount": ${PER_KEY_LIMIT_COUNT}
73 | }
74 | }
75 | EOF
76 |
77 | # Write the expected responses to temporary files.
78 | # Ignore the ISO formatted timestamps to ease the comparison.
79 | cat << EOF > "${CONNECTIONS_EXPECTED_RESPONSE}"
80 | [
81 | {
82 | "asn": null,
83 | "bytesTransferred": "${BYTES_TRANSFERRED1}",
84 | "countries": [
85 | "US",
86 | "NL"
87 | ],
88 | "serverId": "${SERVER_ID}",
89 | "tunnelTimeSec": "${TUNNEL_TIME}"
90 | },
91 | {
92 | "asn": "123",
93 | "bytesTransferred": "${BYTES_TRANSFERRED2}",
94 | "countries": [
95 | "UK"
96 | ],
97 | "serverId": "${SERVER_ID}",
98 | "tunnelTimeSec": null
99 | }
100 | ]
101 | EOF
102 | cat << EOF > "${FEATURES_EXPECTED_RESPONSE}"
103 | [
104 | {
105 | "dataLimit": {
106 | "enabled": "false",
107 | "perKeyLimitCount": "${PER_KEY_LIMIT_COUNT}"
108 | },
109 | "serverId": "${SERVER_ID}",
110 | "serverVersion": "${SERVER_VERSION}"
111 | }
112 | ]
113 | EOF
114 |
115 | echo "Connections request:"
116 | cat "${CONNECTIONS_REQUEST}"
117 | curl -X POST -H "Content-Type: application/json" -d "@${CONNECTIONS_REQUEST}" "${METRICS_URL}/connections" && echo
118 | sleep 5
119 | bq --project_id "${BIGQUERY_PROJECT}" --format json query --nouse_legacy_sql "SELECT serverId, bytesTransferred, tunnelTimeSec, countries, asn FROM \`${BIGQUERY_DATASET}.${CONNECTIONS_TABLE}\` WHERE serverId = \"${SERVER_ID}\" ORDER BY bytesTransferred DESC LIMIT 2" | jq > "${CONNECTIONS_RESPONSE}"
120 | diff "${CONNECTIONS_RESPONSE}" "${CONNECTIONS_EXPECTED_RESPONSE}"
121 |
122 | echo "Features request:"
123 | cat "${FEATURES_REQUEST}"
124 | curl -X POST -H "Content-Type: application/json" -d "@${FEATURES_REQUEST}" "${METRICS_URL}/features" && echo
125 | sleep 5
126 | bq --project_id "${BIGQUERY_PROJECT}" --format json query --nouse_legacy_sql "SELECT serverId, serverVersion, dataLimit FROM \`${BIGQUERY_DATASET}.${FEATURES_TABLE}\` WHERE serverId = \"${SERVER_ID}\" ORDER BY timestamp DESC LIMIT 1" | jq > "${FEATURES_RESPONSE}"
127 | diff "${FEATURES_RESPONSE}" "${FEATURES_EXPECTED_RESPONSE}"
128 |
--------------------------------------------------------------------------------
/src/metrics_server/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es2016",
4 | "removeComments": false,
5 | "strict": true,
6 | "module": "commonjs",
7 | "outDir": "../../build/metrics_server",
8 | "skipLibCheck": true
9 | },
10 | "include": ["**/*.ts"]
11 | }
12 |
--------------------------------------------------------------------------------
/src/sentry_webhook/README.md:
--------------------------------------------------------------------------------
1 | # Outline Sentry Webhook
2 |
3 | The Outline Sentry webhook is a [Google Cloud Run functions](https://cloud.google.com/functions/) that receives a Sentry event and posts it to Salesforce.
4 |
5 | ## Requirements
6 |
7 | - [Google Cloud SDK](https://cloud.google.com/sdk/)
8 | - Access to Outline's Sentry account.
9 |
10 | ## Build
11 |
12 | ```sh
13 | task sentry_webhook:build
14 | ```
15 |
16 | ## Deploy
17 |
18 | Authenticate with `gcloud`:
19 |
20 | ```sh
21 | gcloud auth login
22 | ```
23 |
24 | To deploy:
25 |
26 | ```sh
27 | task sentry_webhook:deploy
28 | ```
29 |
30 | ## Configure Sentry Webhooks
31 |
32 | - Log in to Outline's [Sentry account](https://sentry.io/outlinevpn/)
33 | - Select a project (outline-client, outline-client-dev, outline-server, outline-server-dev).
34 | - Note that this process must be repeated for all Sentry projects.
35 | - Enable the WebHooks plugin at `https://sentry.io/settings/outlinevpn//plugins/`
36 | - Set the webhook endpoint at `https://sentry.io/settings/outlinevpn//plugins/webhooks/`
37 | - Configure alerts to invoke the webhook at `https://sentry.io/settings/outlinevpn//alerts/`
38 | - Create rules to trigger the webhook at `https://sentry.io/settings/outlinevpn//alerts/rules/`
39 |
--------------------------------------------------------------------------------
/src/sentry_webhook/Taskfile.yml:
--------------------------------------------------------------------------------
1 | # Copyright 2024 The Outline Authors
2 | #
3 | # Licensed under the Apache License, Version 2.0 (the "License");
4 | # you may not use this file except in compliance with the License.
5 | # You may obtain a copy of the License at
6 | #
7 | # http://www.apache.org/licenses/LICENSE-2.0
8 | #
9 | # Unless required by applicable law or agreed to in writing, software
10 | # distributed under the License is distributed on an "AS IS" BASIS,
11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | # See the License for the specific language governing permissions and
13 | # limitations under the License.
14 |
15 | version: '3'
16 |
17 | requires:
18 | vars: [OUTPUT_BASE]
19 |
20 | tasks:
21 | clean:
22 | desc: Clean Sentry webhook output
23 | cmds:
24 | - rm -rf "{{.OUTPUT_BASE}}"
25 |
26 | build:
27 | desc: Build the Sentry webhook
28 | cmds:
29 | - npx tsc --project '{{.TASKFILE_DIR}}/tsconfig.prod.json' --outDir '{{.OUTPUT_BASE}}'
30 | - cp '{{.TASKFILE_DIR}}/package.json' '{{.OUTPUT_BASE}}'
31 |
32 | deploy:
33 | desc: Deploy the Sentry webhook to GCP Cloud Functions
34 | deps: [build]
35 | cmds:
36 | - gcloud functions deploy postSentryEventToSalesforce
37 | --project=uproxysite
38 | --runtime=nodejs18
39 | --trigger-http
40 | --source='{{.OUTPUT_BASE}}'
41 | --entry-point=postSentryEventToSalesforce
42 |
43 | test:
44 | desc: Run the unit tests for the Sentry webhook
45 | vars:
46 | TEST_DIR:
47 | sh: "mktemp -d"
48 | cmds:
49 | - defer: rm -rf "{{.TEST_DIR}}"
50 | # Use commonjs modules, jasmine runs in node.
51 | - npx tsc -p '{{.TASKFILE_DIR}}' --outDir '{{.TEST_DIR}}' --module commonjs
52 | - npx jasmine '{{.TEST_DIR}}/**/*.spec.js'
53 | - npx karma start '{{.TASKFILE_DIR}}/karma.conf.js'
54 |
--------------------------------------------------------------------------------
/src/sentry_webhook/event.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright 2024 The Outline Authors
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | import type {SentryEvent as SentryEventBase} from '@sentry/types';
18 |
19 | // Although SentryEvent.tags is declared as an index signature object, it is actually an array of
20 | // arrays i.e. [['key0', 'value0'], ['key1', 'value1']].
21 | export type Tags = null | [string, string][];
22 |
23 | export interface SentryEvent extends Omit {
24 | tags?: Tags | null;
25 | }
26 |
--------------------------------------------------------------------------------
/src/sentry_webhook/index.ts:
--------------------------------------------------------------------------------
1 | // Copyright 2018 The Outline Authors
2 | //
3 | // Licensed under the Apache License, Version 2.0 (the "License");
4 | // you may not use this file except in compliance with the License.
5 | // You may obtain a copy of the License at
6 | //
7 | // http://www.apache.org/licenses/LICENSE-2.0
8 | //
9 | // Unless required by applicable law or agreed to in writing, software
10 | // distributed under the License is distributed on an "AS IS" BASIS,
11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | // See the License for the specific language governing permissions and
13 | // limitations under the License.
14 |
15 | import * as express from 'express';
16 |
17 | import {
18 | postSentryEventToSalesforce,
19 | shouldPostEventToSalesforce,
20 | } from './post_sentry_event_to_salesforce';
21 | import {SentryEvent} from './event';
22 |
23 | exports.postSentryEventToSalesforce = (req: express.Request, res: express.Response) => {
24 | if (req.method !== 'POST') {
25 | return res.status(405).send('Method not allowed');
26 | }
27 | if (!req.body) {
28 | return res.status(400).send('Missing request body');
29 | }
30 |
31 | const sentryEvent: SentryEvent = req.body.event;
32 | if (!sentryEvent) {
33 | return res.status(400).send('Missing Sentry event');
34 | }
35 | const eventId = sentryEvent.event_id?.replace(/\n|\r/g, '');
36 | if (!shouldPostEventToSalesforce(sentryEvent)) {
37 | console.log('Not posting event:', eventId);
38 | return res.status(200).send();
39 | }
40 | // Use the request message if SentryEvent.message is unpopulated.
41 | sentryEvent.message = sentryEvent.message || req.body.message;
42 | postSentryEventToSalesforce(sentryEvent, req.body.project)
43 | .then(() => {
44 | console.log('Successfully posted event:', eventId);
45 | res.status(200).send();
46 | })
47 | .catch((e) => {
48 | console.error(e);
49 | // Send an OK response to Sentry - they don't need to know about errors with posting to
50 | // Salesforce.
51 | res.status(200).send();
52 | });
53 | };
54 |
--------------------------------------------------------------------------------
/src/sentry_webhook/karma.conf.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/no-var-requires */
2 | // Copyright 2020 The Outline Authors
3 | //
4 | // Licensed under the Apache License, Version 2.0 (the "License");
5 | // you may not use this file except in compliance with the License.
6 | // You may obtain a copy of the License at
7 | //
8 | // http://www.apache.org/licenses/LICENSE-2.0
9 | //
10 | // Unless required by applicable law or agreed to in writing, software
11 | // distributed under the License is distributed on an "AS IS" BASIS,
12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | // See the License for the specific language governing permissions and
14 | // limitations under the License.
15 |
16 | const {makeConfig} = require('./test.webpack.js');
17 | process.env.CHROMIUM_BIN = require('puppeteer').executablePath();
18 |
19 | const baseConfig = makeConfig({
20 | defaultMode: 'development',
21 | });
22 |
23 | const TEST_PATTERNS = ['**/*.spec.ts'];
24 |
25 | let preprocessors = {};
26 | for (const pattern of TEST_PATTERNS) {
27 | preprocessors[pattern] = ['webpack'];
28 | }
29 |
30 | module.exports = function (config) {
31 | config.set({
32 | frameworks: ['jasmine'],
33 | files: TEST_PATTERNS,
34 | preprocessors,
35 | reporters: ['progress'],
36 | colors: true,
37 | logLevel: config.LOG_INFO,
38 | browsers: ['ChromiumHeadless'],
39 | restartOnFileChange: true,
40 | singleRun: true,
41 | concurrency: Infinity,
42 | webpack: {
43 | module: baseConfig.module,
44 | resolve: baseConfig.resolve,
45 | plugins: baseConfig.plugins,
46 | mode: baseConfig.mode,
47 | },
48 | });
49 | };
50 |
--------------------------------------------------------------------------------
/src/sentry_webhook/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "sentry_webhook",
3 | "private": true,
4 | "version": "0.1.0",
5 | "description": "Outline Sentry Webhook",
6 | "author": "Outline",
7 | "license": "Apache",
8 | "devDependencies": {
9 | "@sentry/types": "^4.4.1",
10 | "@types/express": "^4.17.12",
11 | "@types/jasmine": "^5.1.0",
12 | "https-browserify": "^1.0.0",
13 | "jasmine": "^5.1.0",
14 | "karma": "^6.4.2",
15 | "karma-chrome-launcher": "^3.2.0",
16 | "karma-jasmine": "^4.0.2",
17 | "karma-webpack": "^5.0.1",
18 | "puppeteer": "^13.6.0",
19 | "stream-http": "^3.2.0",
20 | "url": "^0.11.3",
21 | "webpack": "^5.90.3"
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/src/sentry_webhook/post_sentry_event_to_salesforce.spec.ts:
--------------------------------------------------------------------------------
1 | // Copyright 2023 The Outline Authors
2 | //
3 | // Licensed under the Apache License, Version 2.0 (the "License");
4 | // you may not use this file except in compliance with the License.
5 | // You may obtain a copy of the License at
6 | //
7 | // http://www.apache.org/licenses/LICENSE-2.0
8 | //
9 | // Unless required by applicable law or agreed to in writing, software
10 | // distributed under the License is distributed on an "AS IS" BASIS,
11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | // See the License for the specific language governing permissions and
13 | // limitations under the License.
14 |
15 | import {ClientRequest} from 'http';
16 | import * as https from 'https';
17 |
18 | import {postSentryEventToSalesforce} from './post_sentry_event_to_salesforce';
19 | import {SentryEvent} from './event';
20 |
21 | // NOTE: Jasmine's `toHaveBeenCalledWith` infers parameters for overloads
22 | // incorrectly. See https://github.com/DefinitelyTyped/DefinitelyTyped/issues/42455.
23 | function expectToHaveBeenCalledWith(spy: jasmine.Spy, expected: unknown) {
24 | expect(spy.calls.argsFor(0)[0]).toEqual(expected);
25 | }
26 |
27 | const BASIC_EVENT: SentryEvent = {
28 | user: {email: 'foo@bar.com'},
29 | message: 'my message',
30 | };
31 |
32 | describe('postSentryEventToSalesforce', () => {
33 | let mockRequest: jasmine.SpyObj;
34 | let requestSpy: jasmine.Spy;
35 |
36 | beforeEach(() => {
37 | mockRequest = jasmine.createSpyObj('request', ['on', 'write', 'end']);
38 | requestSpy = spyOn(https, 'request').and.returnValue(mockRequest);
39 | });
40 |
41 | it('sends the correct data for a basic prod event', () => {
42 | postSentryEventToSalesforce(BASIC_EVENT, 'outline-clients');
43 |
44 | const expectedOptions = {
45 | host: 'webto.salesforce.com',
46 | path: '/servlet/servlet.WebToCase',
47 | protocol: 'https:',
48 | method: 'post',
49 | headers: {'Content-Type': 'application/x-www-form-urlencoded'},
50 | };
51 |
52 | expectToHaveBeenCalledWith(requestSpy, expectedOptions);
53 | expectToHaveBeenCalledWith(
54 | mockRequest.write,
55 | 'orgid=00D0b000000BrsN' +
56 | '&recordType=0120b0000006e8i' +
57 | '&email=foo%40bar.com' +
58 | '&00N0b00000BqOA4=' +
59 | '&description=my%20message' +
60 | '&00N5a00000DXxmr=I%20am%20using%20the%20Outline%20client%20application%20on%20my%20mobile%20or%20desktop%20device'
61 | );
62 | expect(mockRequest.end).toHaveBeenCalled();
63 | });
64 |
65 | it('sends the correct data for a basic dev event', () => {
66 | postSentryEventToSalesforce(BASIC_EVENT, 'outline-clients-dev');
67 |
68 | const expectedOptions = {
69 | host: 'google-jigsaw--jigsawuat.sandbox.my.salesforce.com',
70 | path: '/servlet/servlet.WebToCase',
71 | protocol: 'https:',
72 | method: 'post',
73 | headers: {'Content-Type': 'application/x-www-form-urlencoded'},
74 | };
75 |
76 | expectToHaveBeenCalledWith(requestSpy, expectedOptions);
77 | expectToHaveBeenCalledWith(
78 | mockRequest.write,
79 | 'orgid=00D750000004dFg' +
80 | '&recordType=0123F000000MWTS' +
81 | '&email=foo%40bar.com' +
82 | '&00N3F000002Rqhq=' +
83 | '&description=my%20message' +
84 | '&00N75000000wYiX=I%20am%20using%20the%20Outline%20client%20application%20on%20my%20mobile%20or%20desktop%20device'
85 | );
86 | expect(mockRequest.end).toHaveBeenCalled();
87 | });
88 |
89 | it('sends correctly converted tags', () => {
90 | const event: SentryEvent = {
91 | user: {email: 'foo@bar.com'},
92 | message: 'my message',
93 | tags: [
94 | ['category', 'no-server'],
95 | ['subject', 'test subject'],
96 | ['os.name', 'Mac OS X'],
97 | ['sentry:release', 'test version'],
98 | ['build.number', '0.0.0-debug'],
99 | ['accessKeySource', 'test source'],
100 | ['unknown:tag', 'foo'],
101 | ['outreachConsent', 'True'],
102 | ],
103 | };
104 |
105 | postSentryEventToSalesforce(event, 'outline-clients');
106 |
107 | expectToHaveBeenCalledWith(
108 | mockRequest.write,
109 | 'orgid=00D0b000000BrsN' +
110 | '&recordType=0120b0000006e8i' +
111 | '&email=foo%40bar.com' +
112 | '&00N0b00000BqOA4=' +
113 | '&description=my%20message' +
114 | '&00N5a00000DXxmr=I%20am%20using%20the%20Outline%20client%20application%20on%20my%20mobile%20or%20desktop%20device' +
115 | '&00N5a00000DXy19=I%20need%20an%20access%20key' +
116 | '&subject=test%20subject' +
117 | '&00N5a00000DXxmo=MacOS' +
118 | '&00N5a00000DXxmq=test%20version' +
119 | '&00N5a00000DXy64=0.0.0-debug' +
120 | '&00N5a00000DbyEw=true' +
121 | '&00N5a00000DXxms=test%20source'
122 | );
123 | expect(mockRequest.end).toHaveBeenCalled();
124 | });
125 |
126 | it('drops "False" values for `outreachConsent`', () => {
127 | const event: SentryEvent = {
128 | user: {email: 'foo@bar.com'},
129 | message: 'my message',
130 | tags: [['outreachConsent', 'False']],
131 | };
132 |
133 | postSentryEventToSalesforce(event, 'outline-clients');
134 |
135 | expect(mockRequest.write.calls.argsFor(0)[0]).not.toContain('00N5a00000DbyEw');
136 | expect(mockRequest.end).toHaveBeenCalled();
137 | });
138 | });
139 |
--------------------------------------------------------------------------------
/src/sentry_webhook/post_sentry_event_to_salesforce.ts:
--------------------------------------------------------------------------------
1 | // Copyright 2018 The Outline Authors
2 | //
3 | // Licensed under the Apache License, Version 2.0 (the "License");
4 | // you may not use this file except in compliance with the License.
5 | // You may obtain a copy of the License at
6 | //
7 | // http://www.apache.org/licenses/LICENSE-2.0
8 | //
9 | // Unless required by applicable law or agreed to in writing, software
10 | // distributed under the License is distributed on an "AS IS" BASIS,
11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | // See the License for the specific language governing permissions and
13 | // limitations under the License.
14 |
15 | import * as https from 'https';
16 | import {SentryEvent} from './event';
17 |
18 | // Defines the Salesforce form field names.
19 | interface SalesforceFormFields {
20 | orgId: string;
21 | recordType: string;
22 | email: string;
23 | subject: string;
24 | description: string;
25 | issue: string;
26 | accessKeySource: string;
27 | cloudProvider: string;
28 | sentryEventUrl: string;
29 | os: string;
30 | version: string;
31 | build: string;
32 | role: string;
33 | isUpdatedForm: string;
34 | outreachConsent: string;
35 | }
36 |
37 | // Defines the Salesforce form values.
38 | interface SalesforceFormValues {
39 | orgId: string;
40 | recordType: string;
41 | }
42 |
43 | const SALESFORCE_DEV_HOST = 'google-jigsaw--jigsawuat.sandbox.my.salesforce.com';
44 | const SALESFORCE_PROD_HOST = 'webto.salesforce.com';
45 | const SALESFORCE_PATH = '/servlet/servlet.WebToCase';
46 | const SALESFORCE_FORM_FIELDS_DEV: SalesforceFormFields = {
47 | orgId: 'orgid',
48 | recordType: 'recordType',
49 | email: 'email',
50 | subject: 'subject',
51 | description: 'description',
52 | issue: '00N3F000002Rqho',
53 | accessKeySource: '00N75000000wYiY',
54 | cloudProvider: '00N3F000002Rqhs',
55 | sentryEventUrl: '00N3F000002Rqhq',
56 | os: '00N3F000002cLcN',
57 | version: '00N3F000002cLcI',
58 | build: '00N75000000wmdC',
59 | role: '00N75000000wYiX',
60 | isUpdatedForm: '00N75000000wmd7',
61 | outreachConsent: '',
62 | };
63 | const SALESFORCE_FORM_FIELDS_PROD: SalesforceFormFields = {
64 | orgId: 'orgid',
65 | recordType: 'recordType',
66 | email: 'email',
67 | subject: 'subject',
68 | description: 'description',
69 | issue: '00N5a00000DXy19',
70 | accessKeySource: '00N5a00000DXxms',
71 | cloudProvider: '00N5a00000DXxmn',
72 | sentryEventUrl: '00N0b00000BqOA4',
73 | os: '00N5a00000DXxmo',
74 | version: '00N5a00000DXxmq',
75 | build: '00N5a00000DXy64',
76 | role: '00N5a00000DXxmr',
77 | isUpdatedForm: '00N5a00000DXy5a',
78 | outreachConsent: '00N5a00000DbyEw',
79 | };
80 | const SALESFORCE_FORM_VALUES_DEV: SalesforceFormValues = {
81 | orgId: '00D750000004dFg',
82 | recordType: '0123F000000MWTS',
83 | };
84 | const SALESFORCE_FORM_VALUES_PROD: SalesforceFormValues = {
85 | orgId: '00D0b000000BrsN',
86 | recordType: '0120b0000006e8i',
87 | };
88 |
89 | const ISSUE_TYPE_TO_PICKLIST_VALUE: {[key: string]: string} = {
90 | 'cannot-add-server': 'I am having trouble adding a server using my access key',
91 | connection: 'I am having trouble connecting to my Outline VPN server',
92 | general: 'General feedback & suggestions',
93 | managing: 'I need assistance managing my Outline VPN server or helping others connect to it',
94 | 'no-server': 'I need an access key',
95 | performance: 'My internet access is slow while connected to my Outline VPN server',
96 | };
97 |
98 | const CLOUD_PROVIDER_TO_PICKLIST_VALUE: {[key: string]: string} = {
99 | aws: 'Amazon Web Services',
100 | digitalocean: 'DigitalOcean',
101 | gcloud: 'Google Cloud',
102 | other: 'Other',
103 | };
104 |
105 | // Returns whether a Sentry event should be sent to Salesforce by checking that it contains an
106 | // email address.
107 | export function shouldPostEventToSalesforce(event: SentryEvent): boolean {
108 | return !!event.user && !!event.user.email && event.user.email !== '[undefined]';
109 | }
110 |
111 | // Posts a Sentry event to Salesforce using predefined form data. Assumes
112 | // `shouldPostEventToSalesforce` has returned true for `event`.
113 | export function postSentryEventToSalesforce(event: SentryEvent, project: string): Promise {
114 | return new Promise((resolve, reject) => {
115 | // Sentry development projects are marked with 'dev', i.e. outline-client-dev.
116 | const isProd = project.indexOf('-dev') === -1;
117 | const salesforceHost = isProd ? SALESFORCE_PROD_HOST : SALESFORCE_DEV_HOST;
118 | const formFields = isProd ? SALESFORCE_FORM_FIELDS_PROD : SALESFORCE_FORM_FIELDS_DEV;
119 | const formValues = isProd ? SALESFORCE_FORM_VALUES_PROD : SALESFORCE_FORM_VALUES_DEV;
120 | const isClient = project.indexOf('client') !== -1;
121 | const formData = getSalesforceFormData(
122 | formFields,
123 | formValues,
124 | event,
125 | event.user!.email!,
126 | isClient,
127 | project
128 | );
129 | const req = https.request(
130 | {
131 | host: salesforceHost,
132 | path: SALESFORCE_PATH,
133 | protocol: 'https:',
134 | method: 'post',
135 | headers: {
136 | // The production server will reject requests that do not specify this content type.
137 | 'Content-Type': 'application/x-www-form-urlencoded',
138 | },
139 | },
140 | (res) => {
141 | if (res.statusCode === 200) {
142 | console.debug('Salesforce `is-processed`:', res.headers['is-processed']);
143 | resolve();
144 | } else {
145 | reject(new Error(`Failed to post form data, response status: ${res.statusCode}`));
146 | }
147 | }
148 | );
149 | req.on('error', (err) => {
150 | reject(new Error(`Failed to submit form: ${err}`));
151 | });
152 | req.write(formData);
153 | req.end();
154 | });
155 | }
156 |
157 | // Returns a URL-encoded string with the Salesforce form data.
158 | function getSalesforceFormData(
159 | formFields: SalesforceFormFields,
160 | formValues: SalesforceFormValues,
161 | event: SentryEvent,
162 | email: string,
163 | isClient: boolean,
164 | project: string
165 | ): string {
166 | const form = [];
167 | form.push(encodeFormData(formFields.orgId, formValues.orgId));
168 | form.push(encodeFormData(formFields.recordType, formValues.recordType));
169 | form.push(encodeFormData(formFields.email, email));
170 | form.push(encodeFormData(formFields.sentryEventUrl, getSentryEventUrl(project, event.event_id)));
171 | form.push(encodeFormData(formFields.description, event.message));
172 | form.push(
173 | encodeFormData(
174 | formFields.role,
175 | isClient
176 | ? 'I am using the Outline client application on my mobile or desktop device'
177 | : 'I am an Outline server manager'
178 | )
179 | );
180 | if (event.tags) {
181 | const tags = new Map(event.tags);
182 | form.push(encodeFormData(formFields.issue, toIssuePicklistValue(tags.get('category'))));
183 | form.push(encodeFormData(formFields.subject, tags.get('subject')));
184 | form.push(encodeFormData(formFields.os, toOSPicklistValue(tags.get('os.name'))));
185 | form.push(encodeFormData(formFields.version, tags.get('sentry:release')));
186 | form.push(encodeFormData(formFields.build, tags.get('build.number')));
187 | const outreachConsent = (tags.get('outreachConsent') ?? 'False').toLowerCase();
188 | if (outreachConsent === 'true') {
189 | form.push(encodeFormData(formFields.outreachConsent, outreachConsent));
190 | }
191 | const formVersion = Number(tags.get('formVersion') ?? 1);
192 | if (formVersion === 2) {
193 | form.push(encodeFormData(formFields.isUpdatedForm, 'true'));
194 | }
195 | if (isClient) {
196 | form.push(encodeFormData(formFields.accessKeySource, tags.get('accessKeySource')));
197 | } else {
198 | form.push(
199 | encodeFormData(
200 | formFields.cloudProvider,
201 | toCloudProviderPicklistValue(tags.get('cloudProvider'))
202 | )
203 | );
204 | }
205 | }
206 | return form.join('&');
207 | }
208 |
209 | // Returns a picklist value that is allowed by SalesForce for the OS record.
210 | function toOSPicklistValue(value: string | undefined): string | undefined {
211 | if (!value) {
212 | console.warn('No OS found');
213 | return undefined;
214 | }
215 |
216 | const normalizedValue = value.toLowerCase();
217 | if (normalizedValue.includes('android')) {
218 | return 'Android';
219 | }
220 | if (normalizedValue.includes('ios')) {
221 | return 'iOS';
222 | }
223 | if (normalizedValue.includes('windows')) {
224 | return 'Windows';
225 | }
226 | if (normalizedValue.includes('mac')) {
227 | return 'MacOS';
228 | }
229 | return 'Linux';
230 | }
231 |
232 | // Returns a picklist value that is allowed by SalesForce for the issue record.
233 | function toIssuePicklistValue(value: string | undefined): string | undefined {
234 | if (!value) {
235 | console.warn('No issue type found');
236 | return undefined;
237 | }
238 | return ISSUE_TYPE_TO_PICKLIST_VALUE[value];
239 | }
240 |
241 | // Returns a picklist value that is allowed by SalesForce for the cloud provider record.
242 | function toCloudProviderPicklistValue(value: string | undefined): string | undefined {
243 | if (!value) {
244 | console.warn('No cloud provider found');
245 | return undefined;
246 | }
247 | return CLOUD_PROVIDER_TO_PICKLIST_VALUE[value];
248 | }
249 |
250 | function encodeFormData(field: string, value?: string) {
251 | return `${encodeURIComponent(field)}=${encodeURIComponent(value || '')}`;
252 | }
253 |
254 | function getSentryEventUrl(project: string, eventId?: string) {
255 | if (!eventId) {
256 | return '';
257 | }
258 | return `https://sentry.io/outlinevpn/${project}/events/${eventId}`;
259 | }
260 |
--------------------------------------------------------------------------------
/src/sentry_webhook/test.webpack.js:
--------------------------------------------------------------------------------
1 | // Copyright 2023 The Outline Authors
2 | //
3 | // Licensed under the Apache License, Version 2.0 (the "License");
4 | // you may not use this file except in compliance with the License.
5 | // You may obtain a copy of the License at
6 | //
7 | // http://www.apache.org/licenses/LICENSE-2.0
8 | //
9 | // Unless required by applicable law or agreed to in writing, software
10 | // distributed under the License is distributed on an "AS IS" BASIS,
11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | // See the License for the specific language governing permissions and
13 | // limitations under the License.
14 |
15 | exports.makeConfig = (options) => {
16 | return {
17 | mode: options.defaultMode,
18 | target: options.target,
19 | devtool: 'inline-source-map',
20 | module: {
21 | rules: [
22 | {
23 | test: /\.ts(x)?$/,
24 | exclude: /node_modules/,
25 | use: ['ts-loader'],
26 | },
27 | ],
28 | },
29 | resolve: {
30 | extensions: ['.tsx', '.ts', '.js'],
31 | fallback: {
32 | https: require.resolve('https-browserify'),
33 | url: require.resolve('url/'),
34 | http: require.resolve('stream-http'),
35 | },
36 | },
37 | };
38 | };
39 |
--------------------------------------------------------------------------------
/src/sentry_webhook/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es2016",
4 | "removeComments": false,
5 | "strict": true,
6 | "module": "commonjs",
7 | "skipLibCheck": true
8 | },
9 | "include": ["**/*.ts"]
10 | }
11 |
--------------------------------------------------------------------------------
/src/sentry_webhook/tsconfig.prod.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.json",
3 | "exclude": ["**/*.spec.ts"]
4 | }
5 |
--------------------------------------------------------------------------------
/src/server_manager/README.md:
--------------------------------------------------------------------------------
1 | # Outline Manager
2 |
3 | > THIS PROJECT HAS MOVED TO A [NEW LOCATION](https://github.com/Jigsaw-Code/outline-apps/tree/master/): Outline Manager is now part of the [Outline Apps repository](https://github.com/Jigsaw-Code/outline-apps).
4 |
5 | We are keeping this folder to support legacy versions of the app that point to the old [server install script](https://github.com/Jigsaw-Code/outline-server/blob/master/src/server_manager/install_scripts/install_server.sh).
6 |
--------------------------------------------------------------------------------
/src/shadowbox/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # 1.7.2
2 | - Fixes
3 | - Fix reporting of country metrics and improve logging output (https://github.com/Jigsaw-Code/outline-server/pull/1242)
4 |
5 | # 1.7.1
6 | - Fixes
7 | - Corner case of isPortUsed that could result in infinite restart loop (https://github.com/Jigsaw-Code/outline-server/pull/1238)
8 | - Prevent excessive logging (https://github.com/Jigsaw-Code/outline-server/pull/1232)
9 |
10 | # 1.7.0
11 |
12 | - Features
13 | - Add encryption cipher selection to create access key API (https://github.com/Jigsaw-Code/outline-server/pull/1002)
14 | - Make access key secrets longer (https://github.com/Jigsaw-Code/outline-server/pull/1098)
15 | - Fixes
16 | - Race condition on concurrent API calls (https://github.com/Jigsaw-Code/outline-server/pull/995)
17 | - Upgrades (https://github.com/Jigsaw-Code/outline-server/pull/1211)
18 | - Base image to `node:16.18.0-alpine3.16`
19 | - outline-ss-server from 1.3.5 to [1.4.0](https://github.com/Jigsaw-Code/outline-ss-server/releases/tag/v1.4.0)
20 | - Prometheus from 2.33.5 to [2.37.1](https://github.com/prometheus/prometheus/releases/tag/v2.37.1)
21 |
--------------------------------------------------------------------------------
/src/shadowbox/README.md:
--------------------------------------------------------------------------------
1 | # Outline Server (Shadowbox)
2 |
3 | The Outline Server, internal name "Shadowbox," is designed to streamline the setup and sharing of Shadowsocks servers. It includes a user management API and creates Shadowsocks instances when needed. It's managed by the [Outline Manager](https://github.com/Jigsaw-Code/outline-apps/) and used as proxy by the [Outline Client](https://github.com/Jigsaw-Code/outline-apps/) apps. Shadowbox is also compatible with standard Shadowsocks clients.
4 |
5 | ## Installation
6 |
7 | ### Self-Hosted Installation
8 |
9 | 1. **Run the Installation Script**
10 |
11 | ```sh
12 | sudo bash -c "$(wget -qO- https://raw.githubusercontent.com/Jigsaw-Code/outline-apps/master/server_manager/install_scripts/install_server.sh)"
13 | ```
14 |
15 | 1. **Customize (Optional)**
16 |
17 | Add flags for hostname, port, etc. Example:
18 |
19 | ```sh
20 | sudo bash -c "$(wget -qO- https://raw.githubusercontent.com/Jigsaw-Code/outline-apps/master/server_manager/install_scripts/install_server.sh)" install_server.sh \
21 | --hostname=myserver.com \
22 | --keys-port=443
23 | ```
24 |
25 | - Use `sudo --preserve-env` for environment variables.
26 | - Use `bash -x` for debugging.
27 |
28 | ### Running from Source Code
29 |
30 | **Prerequisites**
31 |
32 | - [Docker](https://docs.docker.com/engine/install/)
33 | - [Node](https://nodejs.org/en/download/) LTS (`lts/hydrogen`, version `18.16.0`)
34 | - [NPM](https://docs.npmjs.com/downloading-and-installing-node-js-and-npm) (version `9.5.1`)
35 |
36 | > [!TIP]
37 | > If you use `nvm`, switch to the correct Node version with `nvm use`.
38 |
39 | 1. **Build and Run:**
40 |
41 | Shadowbox supports running on linux and macOS hosts.
42 |
43 | - **Node.js App**
44 |
45 | ```sh
46 | task shadowbox:start
47 | ```
48 |
49 | - **Docker Container**
50 |
51 | ```sh
52 | task shadowbox:docker:start
53 | ```
54 |
55 | > [!TIP]
56 | > Some useful commands when working with Docker images and containers:
57 | >
58 | > - **Debug Image:**
59 | >
60 | > ```sh
61 | > docker run --rm -it --entrypoint=sh localhost/outline/shadowbox
62 | > ```
63 | >
64 | > - **Debug Running Container:**
65 | >
66 | > ```sh
67 | > docker exec -it shadowbox sh
68 | > ```
69 | >
70 | > - **Cleanup Dangling Images:**
71 | >
72 | > ```sh
73 | > docker rmi $(docker images -f dangling=true -q)
74 | > ```
75 |
76 | 1. **Send a Test Request**
77 |
78 | ```sh
79 | curl --insecure https://[::]:8081/TestApiPrefix/server
80 | ```
81 |
82 | ## Access Keys Management API
83 |
84 | The Outline Server provides a REST API for access key management. If you know the `apiUrl` of your Outline Server (e.g. `https://1.2.3.4:1234/3pQ4jf6qSr5WVeMO0XOo4z`), you can directly manage the server's access keys using HTTP requests:
85 |
86 | 1. **Find the Server's `apiUrl`:**
87 |
88 | - **Deployed with the Installation Script:** Run `grep "apiUrl" /opt/outline/access.txt | cut -d: -f 2-`
89 |
90 | - **Deployed with the Outline Manager:** Check the "Settings" tab.
91 |
92 | - **Local Deployments from Source:** The `apiUrl` is simply `https://[::]:8081/TestApiPrefix`
93 |
94 | 1. **API Examples:**
95 |
96 | Replace `$API_URL` with your actual `apiUrl`.
97 |
98 | - **List access keys:** `curl --insecure $API_URL/access-keys/`
99 |
100 | - **Create an access key:** `curl --insecure -X POST $API_URL/access-keys`
101 |
102 | - **Get an access key (e.g. ID 1):** `curl --insecure $API_URL/access-keys/1`
103 |
104 | - **Rename an access key:** `curl --insecure -X PUT -F 'name=albion' $API_URL/access-keys/2/name`
105 |
106 | - **Remove an access key:** `curl --insecure -X DELETE $API_URL/access-keys/1`
107 |
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`
109 |
110 | - **Remove the access key data limit:** `curl --insecure -X DELETE $API_URL/server/access-key-data-limit`
111 |
112 | - **And more...**
113 |
114 | 1. **Further Options:**
115 |
116 | Consult the [OpenAPI spec](./server/api.yml) and [documentation](https://redocly.github.io/redoc/?url=https://raw.githubusercontent.com/Jigsaw-Code/outline-server/master/src/shadowbox/server/api.yml) for more options.
117 |
118 | ## Testing
119 |
120 | ### Manual
121 |
122 | Build and run your image with:
123 |
124 | ```sh
125 | task shadowbox:docker:start
126 | ```
127 |
128 | ### Integration Test
129 |
130 | The integration test will not only build and run your image, but also run a number of automated tests.
131 |
132 | ```sh
133 | task shadowbox:integration_test
134 | ```
135 |
136 | This does the following:
137 |
138 | - Sets up three containers (`client`, `shadowbox`, `target`) and two networks.
139 | - Creates a user on `shadowbox`.
140 | - Connects to `target` through `shadowbox` using a Shadowsocks `client`: `client <-> shadowbox <-> target`
141 |
142 | 1. **Testing Changes to the Server Config:**
143 |
144 | If your change includes new fields in the server config which are needed at server start-up time, then you mey need to remove the pre-existing test config:
145 |
146 | - **Delete Existing Config:** `rm /tmp/outline/persisted-state/shadowbox_server_config.json`
147 |
148 | - **Manually Edit:** You'll need to edit the JSON string within [`src/shadowbox/Taskfile.yml`](src/shadowbox/Taskfile.yml).
149 |
--------------------------------------------------------------------------------
/src/shadowbox/Taskfile.yml:
--------------------------------------------------------------------------------
1 | # Copyright 2024 The Outline Authors
2 | #
3 | # Licensed under the Apache License, Version 2.0 (the "License");
4 | # you may not use this file except in compliance with the License.
5 | # You may obtain a copy of the License at
6 | #
7 | # http://www.apache.org/licenses/LICENSE-2.0
8 | #
9 | # Unless required by applicable law or agreed to in writing, software
10 | # distributed under the License is distributed on an "AS IS" BASIS,
11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | # See the License for the specific language governing permissions and
13 | # limitations under the License.
14 |
15 | version: '3'
16 |
17 | requires:
18 | vars: [OUTPUT_BASE]
19 |
20 | tasks:
21 | build:
22 | desc: Build the Outline Server Node.js app
23 | vars:
24 | TARGET_OS: '{{.TARGET_OS | default "linux"}}'
25 | TARGET_ARCH: '{{.TARGET_ARCH | default "x86_64"}}'
26 | GOARCH: '{{get (dict "x86_64" "amd64") .TARGET_ARCH | default .TARGET_ARCH}}'
27 | TARGET_DIR: '{{.TARGET_DIR | default (joinPath .OUTPUT_BASE .TARGET_OS .TARGET_ARCH)}}'
28 | NODE_DIR: '{{joinPath .TARGET_DIR "app"}}'
29 | BIN_DIR: '{{joinPath .TARGET_DIR "bin"}}'
30 | VERSION: '{{.VERSION}}'
31 | cmds:
32 | - echo Target platform is {{.TARGET_OS}}/{{.TARGET_ARCH}}
33 | - rm -rf '{{.TARGET_DIR}}'
34 | - mkdir -p '{{.TARGET_DIR}}'
35 | - cp '{{joinPath .TASKFILE_DIR "package.json"}}' '{{.TARGET_DIR}}'
36 | # Build Node.js app
37 | - SB_VERSION={{.VERSION}} npx webpack --config='{{joinPath .TASKFILE_DIR "webpack.config.js"}}' --output-path='{{.NODE_DIR}}' ${BUILD_ENV:+--mode="${BUILD_ENV}"}
38 | # Copy third_party dependencies
39 | - task: ':third_party:prometheus:copy-{{.TARGET_OS}}-{{.GOARCH}}'
40 | vars: {TARGET_DIR: '{{.BIN_DIR}}'}
41 | # Set CGO_ENABLED=0 to force static linkage. See https://mt165.co.uk/blog/static-link-go/.
42 | - GOOS={{.TARGET_OS}} GOARCH={{.GOARCH}} CGO_ENABLED=0 go build -ldflags='-s -w -X main.version=embedded' -o '{{.BIN_DIR}}/' github.com/Jigsaw-Code/outline-ss-server/cmd/outline-ss-server
43 |
44 | start:
45 | desc: Run the Outline server locally
46 | deps: [{task: build, vars: {TARGET_OS: '{{.TARGET_OS}}', TARGET_ARCH: '{{.TARGET_ARCH}}'}}]
47 | vars:
48 | TARGET_OS: {sh: "uname -s | tr '[:upper:]' '[:lower:]'"}
49 | TARGET_ARCH: {sh: 'uname -m'}
50 | RUN_ID: '{{.RUN_ID | default (now | date "2006-01-02-150405")}}'
51 | RUN_DIR: '{{joinPath "/tmp/outline" .RUN_ID}}'
52 | STATE_DIR: '{{joinPath .RUN_DIR "persisted-state"}}'
53 | STATE_CONFIG: '{{joinPath .STATE_DIR "shadowbox_server_config.json"}}'
54 | LOG_LEVEL: '{{.LOG_LEVEL | default "debug"}}'
55 | env:
56 | # WARNING: The SB_API_PREFIX should be kept secret!
57 | SB_API_PREFIX: TestApiPrefix
58 | SB_METRICS_URL: https://dev.metrics.getoutline.org
59 | SB_STATE_DIR: '{{.STATE_DIR}}'
60 | SB_PUBLIC_IP: localhost
61 | SB_CERTIFICATE_FILE: '{{joinPath .RUN_DIR "/shadowbox-selfsigned-dev.crt"}}'
62 | SB_PRIVATE_KEY_FILE: '{{joinPath .RUN_DIR "/shadowbox-selfsigned-dev.key"}}'
63 | cmds:
64 | - echo Target platform is {{.TARGET_OS}}/{{.TARGET_ARCH}}
65 | - echo "Using directory {{.RUN_DIR}}"
66 | - mkdir -p '{{.STATE_DIR}}'
67 | - echo '{"hostname":"127.0.0.1"}' > "{{.STATE_CONFIG}}"
68 | - task: make_test_certificate
69 | vars: {OUTPUT_DIR: '{{.RUN_DIR}}'}
70 | - node '{{joinPath .OUTPUT_BASE .TARGET_OS .TARGET_ARCH "app/main.js"}}'
71 |
72 | docker:build:
73 | desc: Build the Outline Server Docker image
74 | vars:
75 | VERSION: '{{.IMAGE_VERSION | default "dev"}}'
76 | IMAGE_NAME: '{{.IMAGE_NAME | default "localhost/outline/shadowbox"}}'
77 | TARGET_ARCH: '{{.TARGET_ARCH | default "x86_64"}}'
78 | IMAGE_ROOT: '{{joinPath .OUTPUT_BASE "image_root" .TARGET_ARCH}}'
79 | # Newer node images have no valid content trust data.
80 | # Pin the image node:16.18.0-alpine3.16 by hash.
81 | # See image at https://hub.docker.com/_/node/tags?page=1&name=18.18.0-alpine3.18
82 | NODE_IMAGE: '{{get
83 | (dict
84 | "x86_64" "node@sha256:a0b787b0d53feacfa6d606fb555e0dbfebab30573277f1fe25148b05b66fa097"
85 | "arm64" "node@sha256:b4b7a1dd149c65ee6025956ac065a843b4409a62068bd2b0cbafbb30ca2fab3b"
86 | ) .TARGET_ARCH
87 | }}'
88 | env:
89 | DOCKER_CONTENT_TRUST: '{{.DOCKER_CONTENT_TRUST | default "1"}}'
90 | # Enable Docker BuildKit (https://docs.docker.com/develop/develop-images/build_enhancements)
91 | DOCKER_BUILDKIT: 1
92 | cmds:
93 | - rm -rf '{{.IMAGE_ROOT}}'
94 | - mkdir -p '{{.IMAGE_ROOT}}'
95 | - {task: build, vars: {VERSION: '{{.VERSION}}', TARGET_OS: linux, TARGET_ARCH: '{{.TARGET_ARCH}}', TARGET_DIR: '{{joinPath .IMAGE_ROOT "/opt/outline-server"}}'}}
96 | - cp -R '{{joinPath .TASKFILE_DIR "scripts"}}' '{{.IMAGE_ROOT}}/scripts'
97 | - mkdir -p '{{joinPath .IMAGE_ROOT "/etc/periodic/weekly"}}'
98 | - cp '{{joinPath .TASKFILE_DIR "scripts" "update_mmdb.sh"}}' '{{joinPath .IMAGE_ROOT "/etc/periodic/weekly/"}}'
99 | # Create default state directory
100 | - mkdir -p '{{joinPath .IMAGE_ROOT "/root/shadowbox/persisted-state"}}'
101 | # Copy entrypoint command
102 | - cp '{{joinPath .TASKFILE_DIR "docker/cmd.sh"}}' '{{.IMAGE_ROOT}}/'
103 | # Build image with given root
104 | - |
105 | "${DOCKER:-docker}" build --force-rm \
106 | --build-arg NODE_IMAGE='{{.NODE_IMAGE}}' \
107 | --build-arg VERSION='{{.VERSION}}' \
108 | -f '{{joinPath .TASKFILE_DIR "docker" "Dockerfile"}}' \
109 | -t '{{.IMAGE_NAME}}' \
110 | '{{.IMAGE_ROOT}}'
111 |
112 | docker:start:
113 | desc: Build and run the Outline Server Docker image
114 | interactive: true
115 | requires:
116 | vars: [DOCKER]
117 | deps: [{task: docker:build, vars: {TARGET_ARCH: {sh: 'uname -m'}}}]
118 | vars:
119 | RUN_DIR: '{{joinPath .OUTPUT_BASE "docker_start"}}'
120 | IMAGE_NAME: '{{.IMAGE_NAME | default "localhost/outline/shadowbox"}}'
121 | API_PORT: '8081'
122 | ACCESS_KEY_PORT: '9999'
123 | CERTIFICATE_FILE: 'shadowbox-selfsigned-dev.crt'
124 | PRIVATE_KEY_FILE: 'shadowbox-selfsigned-dev.key'
125 | HOST_STATE_DIR: '{{joinPath .RUN_DIR "persisted-state"}}'
126 | CONTAINER_STATE_DIR: '/opt/outline/pesisted-state'
127 | STATE_CONFIG: '{{joinPath .HOST_STATE_DIR "shadowbox_server_config.json"}}'
128 | cmds:
129 | - rm -rf '{{.RUN_DIR}}'
130 | - mkdir -p '{{.HOST_STATE_DIR}}'
131 | - echo '{"hostname":"127.0.0.1"}' > "{{.STATE_CONFIG}}"
132 | - task: make_test_certificate
133 | vars: {OUTPUT_DIR: '{{.HOST_STATE_DIR}}'}
134 | - |
135 | docker_command=(
136 | '{{.DOCKER}}' run -it --rm --name 'shadowbox'
137 | {{if eq OS "linux" -}}
138 | --net host
139 | {{else}}
140 | -p '{{.API_PORT}}:{{.API_PORT}}'
141 | -p '{{.ACCESS_KEY_PORT}}:{{.ACCESS_KEY_PORT}}'
142 | -p '{{.ACCESS_KEY_PORT}}:{{.ACCESS_KEY_PORT}}/udp'
143 | -p '9090-9092:9090-9092'
144 | {{- end}}
145 |
146 | # Where the container keeps its persistent state.
147 | -v "{{.HOST_STATE_DIR}}:{{.CONTAINER_STATE_DIR}}"
148 | -e "SB_STATE_DIR={{.CONTAINER_STATE_DIR}}"
149 |
150 | # Port number and path prefix used by the server manager API.
151 | -e "SB_API_PORT={{.API_PORT}}"
152 | -e "SB_API_PREFIX=TestApiPrefix"
153 |
154 | # Location of the API TLS certificate and key.
155 | -e "SB_CERTIFICATE_FILE={{joinPath .CONTAINER_STATE_DIR .CERTIFICATE_FILE}}"
156 | -e "SB_PRIVATE_KEY_FILE={{joinPath .CONTAINER_STATE_DIR .PRIVATE_KEY_FILE}}"
157 |
158 | # Where to report metrics to, if opted-in.
159 | -e "SB_METRICS_URL={{.METRICS_URL | default "https://dev.metrics.getoutline.org"}}"
160 |
161 | # The Outline server image to run.
162 | '{{.IMAGE_NAME}}'
163 | )
164 | "${docker_command[@]}"
165 |
166 | integration_test:
167 | desc: Run the integration test
168 | cmds:
169 | - task: docker:build
170 | vars: {TARGET_ARCH: {sh: 'uname -m'}}
171 | - task: test_image
172 | vars:
173 | IMAGE_NAME: localhost/outline/shadowbox:latest
174 | OUTPUT_DIR: '{{joinPath .OUTPUT_BASE "integration_test"}}'
175 |
176 | test_image:
177 | desc: Test a specific image by name
178 | requires:
179 | vars: [IMAGE_NAME]
180 | vars:
181 | OUTPUT_DIR: '{{joinPath .OUTPUT_BASE "image_test"}}'
182 | cmds:
183 | - rm -rf '{{.OUTPUT_DIR}}'
184 | - OUTPUT_DIR='{{.OUTPUT_DIR}}' '{{joinPath .TASKFILE_DIR "integration_test/test.sh"}}' '{{.IMAGE_NAME}}'
185 |
186 |
187 | test:
188 | desc: Run the unit tests for the Outline Server
189 | vars:
190 | TEST_DIR: '{{joinPath .OUTPUT_BASE "test"}}'
191 | cmds:
192 | - defer: rm -rf "{{.TEST_DIR}}"
193 | - npx tsc -p '{{.TASKFILE_DIR}}' --outDir '{{.TEST_DIR}}'
194 | - npx jasmine '{{.TEST_DIR}}/**/*.spec.js'
195 |
196 | make_test_certificate:
197 | internal: true
198 | requires: {vars: [OUTPUT_DIR]}
199 | vars:
200 | CERTIFICATE_FILE: '{{joinPath .OUTPUT_DIR "shadowbox-selfsigned-dev.crt"}}'
201 | PRIVATE_KEY_FILE: '{{joinPath .OUTPUT_DIR "shadowbox-selfsigned-dev.key"}}'
202 | cmds:
203 | - mkdir -p '{{.OUTPUT_DIR}}'
204 | - >
205 | openssl req -x509 -nodes -days 36500 -newkey rsa:4096
206 | -subj "/CN=localhost"
207 | -keyout "{{.PRIVATE_KEY_FILE}}" -out "{{.CERTIFICATE_FILE}}"
--------------------------------------------------------------------------------
/src/shadowbox/docker/Dockerfile:
--------------------------------------------------------------------------------
1 | # Copyright 2018 The Outline Authors
2 | #
3 | # Licensed under the Apache License, Version 2.0 (the "License");
4 | # you may not use this file except in compliance with the License.
5 | # You may obtain a copy of the License at
6 | #
7 | # http://www.apache.org/licenses/LICENSE-2.0
8 | #
9 | # Unless required by applicable law or agreed to in writing, software
10 | # distributed under the License is distributed on an "AS IS" BASIS,
11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | # See the License for the specific language governing permissions and
13 | # limitations under the License.
14 |
15 | ARG NODE_IMAGE
16 |
17 | FROM ${NODE_IMAGE}
18 | ARG VERSION
19 |
20 | # Save metadata on the software versions we are using.
21 | LABEL shadowbox.node_version=16.18.0
22 |
23 | LABEL shadowbox.github.release=${VERSION}
24 |
25 | # The user management service doesn't quit with SIGTERM.
26 | STOPSIGNAL SIGKILL
27 |
28 | # We use curl to detect the server's public IP. We need to use the --date option in `date` to
29 | # safely grab the ip-to-country database.
30 | RUN apk add --no-cache --upgrade coreutils curl
31 |
32 | COPY . /
33 |
34 | RUN /etc/periodic/weekly/update_mmdb.sh
35 |
36 | # Install shadowbox.
37 | WORKDIR /opt/outline-server
38 |
39 | CMD ["/cmd.sh"]
40 |
--------------------------------------------------------------------------------
/src/shadowbox/docker/cmd.sh:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 | #
3 | # Copyright 2018 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.
16 |
17 | export SB_PUBLIC_IP="${SB_PUBLIC_IP:-$(curl --silent https://ipinfo.io/ip)}"
18 | export SB_METRICS_URL="${SB_METRICS_URL:-https://prod.metrics.getoutline.org}"
19 |
20 | # Make sure we don't leak readable files to other users.
21 | umask 0007
22 |
23 | # Start cron, which is used to check for updates to the IP-to-country database
24 | crond
25 |
26 | node app/main.js
27 |
--------------------------------------------------------------------------------
/src/shadowbox/infrastructure/clock.ts:
--------------------------------------------------------------------------------
1 | // Copyright 2018 The Outline Authors
2 | //
3 | // Licensed under the Apache License, Version 2.0 (the "License");
4 | // you may not use this file except in compliance with the License.
5 | // You may obtain a copy of the License at
6 | //
7 | // http://www.apache.org/licenses/LICENSE-2.0
8 | //
9 | // Unless required by applicable law or agreed to in writing, software
10 | // distributed under the License is distributed on an "AS IS" BASIS,
11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | // See the License for the specific language governing permissions and
13 | // limitations under the License.
14 |
15 | export interface Clock {
16 | // Returns the current time in milliseconds from the epoch.
17 | now(): number;
18 | setInterval(callback: () => void, intervalMs: number): void;
19 | }
20 |
21 | export class RealClock implements Clock {
22 | now(): number {
23 | return Date.now();
24 | }
25 |
26 | setInterval(callback, intervalMs: number): void {
27 | setInterval(callback, intervalMs);
28 | }
29 | }
30 |
31 | // Fake clock where you manually set what is "now" and can trigger the scheduled callbacks.
32 | // Useful for tests.
33 | export class ManualClock implements Clock {
34 | nowMs = 0;
35 | private callbacks = [] as (() => void)[];
36 |
37 | now(): number {
38 | return this.nowMs;
39 | }
40 |
41 | setInterval(callback, _intervalMs): void {
42 | this.callbacks.push(callback);
43 | }
44 |
45 | async runCallbacks() {
46 | for (const callback of this.callbacks) {
47 | await callback();
48 | }
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/src/shadowbox/infrastructure/file.spec.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright 2024 The Outline Authors
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | import * as fs from 'fs';
18 | import * as tmp from 'tmp';
19 | import * as file from './file';
20 |
21 | describe('file', () => {
22 | tmp.setGracefulCleanup();
23 |
24 | describe('readFileIfExists', () => {
25 | let tmpFile: tmp.FileResult;
26 |
27 | beforeEach(() => (tmpFile = tmp.fileSync()));
28 |
29 | it('reads the file if it exists', () => {
30 | const contents = 'test';
31 |
32 | fs.writeFileSync(tmpFile.name, contents);
33 |
34 | expect(file.readFileIfExists(tmpFile.name)).toBe(contents);
35 | });
36 |
37 | it('reads the file if it exists and is empty', () => {
38 | fs.writeFileSync(tmpFile.name, '');
39 |
40 | expect(file.readFileIfExists(tmpFile.name)).toBe('');
41 | });
42 |
43 | it("returns null if file doesn't exist", () =>
44 | expect(file.readFileIfExists(tmp.tmpNameSync())).toBe(null));
45 | });
46 |
47 | describe('atomicWriteFileSync', () => {
48 | let tmpFile: tmp.FileResult;
49 |
50 | beforeEach(() => (tmpFile = tmp.fileSync()));
51 |
52 | it('writes to the file', () => {
53 | const contents = 'test';
54 |
55 | file.atomicWriteFileSync(tmpFile.name, contents);
56 |
57 | expect(fs.readFileSync(tmpFile.name, {encoding: 'utf8'})).toEqual(contents);
58 | });
59 |
60 | it('supports multiple simultaneous writes to the same file', async () => {
61 | const writeCount = 100;
62 |
63 | const writer = (_, id) =>
64 | new Promise((resolve, reject) => {
65 | try {
66 | file.atomicWriteFileSync(
67 | tmpFile.name,
68 | `${fs.readFileSync(tmpFile.name, {encoding: 'utf-8'})}${id}\n`
69 | );
70 | resolve();
71 | } catch (e) {
72 | reject(e);
73 | }
74 | });
75 |
76 | await Promise.all(Array.from({length: writeCount}, writer));
77 |
78 | expect(fs.readFileSync(tmpFile.name, {encoding: 'utf8'}).trimEnd().split('\n').length).toBe(
79 | writeCount
80 | );
81 | });
82 | });
83 | });
84 |
--------------------------------------------------------------------------------
/src/shadowbox/infrastructure/file.ts:
--------------------------------------------------------------------------------
1 | // Copyright 2018 The Outline Authors
2 | //
3 | // Licensed under the Apache License, Version 2.0 (the "License");
4 | // you may not use this file except in compliance with the License.
5 | // You may obtain a copy of the License at
6 | //
7 | // http://www.apache.org/licenses/LICENSE-2.0
8 | //
9 | // Unless required by applicable law or agreed to in writing, software
10 | // distributed under the License is distributed on an "AS IS" BASIS,
11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | // See the License for the specific language governing permissions and
13 | // limitations under the License.
14 |
15 | import * as fs from 'fs';
16 |
17 | // Reads a text file if it exists, or null if the file is not found.
18 | // Throws any other error except file not found.
19 | export function readFileIfExists(filename: string): string {
20 | try {
21 | return fs.readFileSync(filename, {encoding: 'utf8'}) ?? null;
22 | } catch (err) {
23 | // err.code will be 'ENOENT' if the file is not found, this is expected.
24 | if (err.code === 'ENOENT') {
25 | return null;
26 | } else {
27 | throw err;
28 | }
29 | }
30 | }
31 |
32 | // Write to temporary file, then move that temporary file to the
33 | // persistent location, to avoid accidentally breaking the metrics file.
34 | // Use *Sync calls for atomic operations, to guard against corrupting
35 | // these files.
36 | export function atomicWriteFileSync(filename: string, filebody: string) {
37 | const tempFilename = `${filename}.${Date.now()}`;
38 | fs.writeFileSync(tempFilename, filebody, {encoding: 'utf8'});
39 | fs.renameSync(tempFilename, filename);
40 | }
41 |
--------------------------------------------------------------------------------
/src/shadowbox/infrastructure/filesystem_text_file.ts:
--------------------------------------------------------------------------------
1 | // Copyright 2018 The Outline Authors
2 | //
3 | // Licensed under the Apache License, Version 2.0 (the "License");
4 | // you may not use this file except in compliance with the License.
5 | // You may obtain a copy of the License at
6 | //
7 | // http://www.apache.org/licenses/LICENSE-2.0
8 | //
9 | // Unless required by applicable law or agreed to in writing, software
10 | // distributed under the License is distributed on an "AS IS" BASIS,
11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | // See the License for the specific language governing permissions and
13 | // limitations under the License.
14 |
15 | import * as fs from 'fs';
16 | import {TextFile} from './text_file';
17 |
18 | // Reads a text file if it exists, or null if the file is not found.
19 | // Throws any other error except file not found.
20 | export class FilesystemTextFile implements TextFile {
21 | constructor(private readonly filename: string) {}
22 |
23 | readFileSync(): string {
24 | return fs.readFileSync(this.filename, {encoding: 'utf8'});
25 | }
26 |
27 | writeFileSync(text: string): void {
28 | fs.writeFileSync(this.filename, text, {encoding: 'utf8'});
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/src/shadowbox/infrastructure/follow_redirects.ts:
--------------------------------------------------------------------------------
1 | // Copyright 2018 The Outline Authors
2 | //
3 | // Licensed under the Apache License, Version 2.0 (the "License");
4 | // you may not use this file except in compliance with the License.
5 | // You may obtain a copy of the License at
6 | //
7 | // http://www.apache.org/licenses/LICENSE-2.0
8 | //
9 | // Unless required by applicable law or agreed to in writing, software
10 | // distributed under the License is distributed on an "AS IS" BASIS,
11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | // See the License for the specific language governing permissions and
13 | // limitations under the License.
14 |
15 | import fetch, {RequestInit, Response} from 'node-fetch';
16 |
17 | // Makes an http(s) request, and follows any redirect with the same request
18 | // without changing the request method or body. This is used because typical
19 | // http(s) clients follow redirects for POST/PUT/DELETE requests by changing the
20 | // method to GET and removing the request body. The options parameter matches the
21 | // fetch() function.
22 | export async function requestFollowRedirectsWithSameMethodAndBody(
23 | url: string,
24 | options: RequestInit
25 | ): Promise {
26 | // Make a copy of options to modify parameters.
27 | const manualRedirectOptions = {
28 | ...options,
29 | redirect: 'manual' as RequestRedirect,
30 | };
31 | let response: Response;
32 | for (let i = 0; i < 10; i++) {
33 | response = await fetch(url, manualRedirectOptions);
34 | if (response.status >= 300 && response.status < 400) {
35 | url = response.headers.get('location');
36 | } else {
37 | break;
38 | }
39 | }
40 | return response;
41 | }
42 |
--------------------------------------------------------------------------------
/src/shadowbox/infrastructure/get_port.spec.ts:
--------------------------------------------------------------------------------
1 | // Copyright 2018 The Outline Authors
2 | //
3 | // Licensed under the Apache License, Version 2.0 (the "License");
4 | // you may not use this file except in compliance with the License.
5 | // You may obtain a copy of the License at
6 | //
7 | // http://www.apache.org/licenses/LICENSE-2.0
8 | //
9 | // Unless required by applicable law or agreed to in writing, software
10 | // distributed under the License is distributed on an "AS IS" BASIS,
11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | // See the License for the specific language governing permissions and
13 | // limitations under the License.
14 |
15 | import * as net from 'net';
16 |
17 | import * as get_port from './get_port';
18 |
19 | describe('PortProvider', () => {
20 | describe('addReservedPort', () => {
21 | it('gets port over 1023', async () => {
22 | expect(await new get_port.PortProvider().reserveNewPort()).toBeGreaterThan(1023);
23 | });
24 |
25 | it('fails on double reservation', () => {
26 | const ports = new get_port.PortProvider();
27 | ports.addReservedPort(8080);
28 | expect(() => ports.addReservedPort(8080)).toThrowError();
29 | });
30 | });
31 |
32 | describe('reserveFirstFreePort', () => {
33 | it('returns free port', async () => {
34 | const ports = new get_port.PortProvider();
35 | const server = await listen();
36 | const initialPort = (server.address() as net.AddressInfo).port;
37 | const reservedPort = await ports.reserveFirstFreePort(initialPort);
38 | await closeServer(server);
39 | expect(reservedPort).toBeGreaterThan(initialPort);
40 | });
41 |
42 | it('respects reserved ports', async () => {
43 | const ports = new get_port.PortProvider();
44 | ports.addReservedPort(9090);
45 | ports.addReservedPort(9091);
46 | expect(await ports.reserveFirstFreePort(9090)).toBeGreaterThan(9091);
47 | });
48 | });
49 |
50 | describe('reserveNewPort', () => {
51 | it('Returns a port not in use', async () => {
52 | // We run 100 times to try to trigger possible race conditions.
53 | for (let i = 0; i < 100; ++i) {
54 | const port = await new get_port.PortProvider().reserveNewPort();
55 | expect(await get_port.isPortUsed(port)).toBeFalsy();
56 | }
57 | });
58 | });
59 | });
60 |
61 | describe('isPortUsed', () => {
62 | it('Identifies a port in use on IPV4', async () => {
63 | const port = 12345;
64 | const server = new net.Server();
65 | const isPortUsed = await new Promise((resolve) => {
66 | server.listen(port, '127.0.0.1', () => {
67 | resolve(get_port.isPortUsed(port));
68 | });
69 | });
70 | await closeServer(server);
71 | expect(isPortUsed).toBeTruthy();
72 | });
73 | it('Identifies a port in use on IPV6', async () => {
74 | const port = 12345;
75 | const server = new net.Server();
76 | const isPortUsed = await new Promise((resolve) => {
77 | server.listen(port, '::1', () => {
78 | resolve(get_port.isPortUsed(port));
79 | });
80 | });
81 | await closeServer(server);
82 | expect(isPortUsed).toBeTruthy();
83 | });
84 | it('Identifies a port not in use', async () => {
85 | const port = await new get_port.PortProvider().reserveNewPort();
86 | expect(await get_port.isPortUsed(port)).toBeFalsy();
87 | });
88 | });
89 |
90 | function listen(): Promise {
91 | const server = net.createServer();
92 | return new Promise((resolve, _reject) => {
93 | server.listen({host: 'localhost', port: 0, exclusive: true}, () => {
94 | resolve(server);
95 | });
96 | });
97 | }
98 |
99 | function closeServer(server: net.Server): Promise {
100 | return new Promise((resolve, reject) => {
101 | server.close(err => err ? reject(err) : resolve());
102 | });
103 | }
104 |
--------------------------------------------------------------------------------
/src/shadowbox/infrastructure/get_port.ts:
--------------------------------------------------------------------------------
1 | // Copyright 2018 The Outline Authors
2 | //
3 | // Licensed under the Apache License, Version 2.0 (the "License");
4 | // you may not use this file except in compliance with the License.
5 | // You may obtain a copy of the License at
6 | //
7 | // http://www.apache.org/licenses/LICENSE-2.0
8 | //
9 | // Unless required by applicable law or agreed to in writing, software
10 | // distributed under the License is distributed on an "AS IS" BASIS,
11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | // See the License for the specific language governing permissions and
13 | // limitations under the License.
14 |
15 | import * as net from 'net';
16 |
17 | const MAX_PORT = 65535;
18 | const MIN_PORT = 1024;
19 |
20 | export class PortProvider {
21 | private reservedPorts = new Set();
22 |
23 | addReservedPort(port: number) {
24 | if (this.reservedPorts.has(port)) {
25 | throw new Error(`Port ${port} is already reserved`);
26 | }
27 | this.reservedPorts.add(port);
28 | }
29 |
30 | // Returns the first free port equal or after initialPort
31 | async reserveFirstFreePort(initialPort: number): Promise {
32 | for (let port = initialPort; port < 65536; port++) {
33 | if (!this.reservedPorts.has(port) && !(await isPortUsed(port))) {
34 | this.reservedPorts.add(port);
35 | return port;
36 | }
37 | }
38 | throw new Error('port not found');
39 | }
40 |
41 | async reserveNewPort(): Promise {
42 | // TODO: consider using a set of available ports, so we don't randomly
43 | // try the same port multiple times.
44 | for (;;) {
45 | const port = getRandomPortOver1023();
46 | if (this.reservedPorts.has(port)) {
47 | continue;
48 | }
49 | if (await isPortUsed(port)) {
50 | continue;
51 | }
52 | this.reservedPorts.add(port);
53 | return port;
54 | }
55 | }
56 | }
57 |
58 | function getRandomPortOver1023() {
59 | return Math.floor(Math.random() * (MAX_PORT + 1 - MIN_PORT) + MIN_PORT);
60 | }
61 |
62 | interface ServerError extends Error {
63 | code: string;
64 | }
65 |
66 | export function isPortUsed(port: number): Promise {
67 | return new Promise((resolve, reject) => {
68 | let isUsed = false;
69 | const server = new net.Server();
70 | server.on('error', (error: ServerError) => {
71 | if (error.code === 'EADDRINUSE') {
72 | isUsed = true;
73 | } else {
74 | reject(error);
75 | }
76 | server.close();
77 | });
78 | server.listen({port, exclusive: true}, () => {
79 | isUsed = false;
80 | server.close();
81 | });
82 | server.on('close', () => {
83 | resolve(isUsed);
84 | });
85 | });
86 | }
87 |
--------------------------------------------------------------------------------
/src/shadowbox/infrastructure/json_config.ts:
--------------------------------------------------------------------------------
1 | // Copyright 2018 The Outline Authors
2 | //
3 | // Licensed under the Apache License, Version 2.0 (the "License");
4 | // you may not use this file except in compliance with the License.
5 | // You may obtain a copy of the License at
6 | //
7 | // http://www.apache.org/licenses/LICENSE-2.0
8 | //
9 | // Unless required by applicable law or agreed to in writing, software
10 | // distributed under the License is distributed on an "AS IS" BASIS,
11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | // See the License for the specific language governing permissions and
13 | // limitations under the License.
14 |
15 | import * as file from './file';
16 | import * as logging from './logging';
17 |
18 | export interface JsonConfig {
19 | // Returns a reference (*not* a copy) to the json object backing the config.
20 | data(): T;
21 | // Writes the config to the backing storage.
22 | write();
23 | }
24 |
25 | export function loadFileConfig(filename: string): JsonConfig {
26 | const text = file.readFileIfExists(filename);
27 | let dataJson = {} as T;
28 | if (text) {
29 | dataJson = JSON.parse(text) as T;
30 | }
31 | return new FileConfig(filename, dataJson);
32 | }
33 |
34 | // FileConfig is a JsonConfig backed by a filesystem file.
35 | export class FileConfig implements JsonConfig {
36 | constructor(private filename: string, private dataJson: T) {}
37 |
38 | data(): T {
39 | return this.dataJson;
40 | }
41 |
42 | write() {
43 | try {
44 | file.atomicWriteFileSync(this.filename, JSON.stringify(this.dataJson));
45 | } catch (error) {
46 | // TODO: Stop swallowing the exception and handle it in the callers.
47 | logging.error(`Error writing config ${this.filename} ${error}`);
48 | }
49 | }
50 | }
51 |
52 | // ChildConfig is a JsonConfig backed by another config.
53 | export class ChildConfig implements JsonConfig {
54 | constructor(private parentConfig: JsonConfig<{}>, private dataJson: T) {}
55 |
56 | data(): T {
57 | return this.dataJson;
58 | }
59 |
60 | write() {
61 | this.parentConfig.write();
62 | }
63 | }
64 |
65 | // DelayedConfig is a JsonConfig that only writes the data in a periodic time interval.
66 | // Calls to write() will mark the data as "dirty" for the next inverval.
67 | export class DelayedConfig implements JsonConfig {
68 | private dirty = false;
69 | constructor(private config: JsonConfig, writePeriodMs: number) {
70 | // This repeated call will never be cancelled until the execution is terminated.
71 | setInterval(() => {
72 | if (!this.dirty) {
73 | return;
74 | }
75 | this.config.write();
76 | this.dirty = false;
77 | }, writePeriodMs);
78 | }
79 |
80 | data(): T {
81 | return this.config.data();
82 | }
83 |
84 | write() {
85 | this.dirty = true;
86 | }
87 | }
88 |
89 | // InMemoryConfig is a JsonConfig backed by an internal member variable. Useful for testing.
90 | export class InMemoryConfig implements JsonConfig {
91 | // Holds the data JSON as it was when `write()` was called.
92 | mostRecentWrite: T;
93 | constructor(private dataJson: T) {
94 | this.mostRecentWrite = this.dataJson;
95 | }
96 |
97 | data(): T {
98 | return this.dataJson;
99 | }
100 |
101 | write() {
102 | this.mostRecentWrite = JSON.parse(JSON.stringify(this.dataJson));
103 | }
104 | }
105 |
--------------------------------------------------------------------------------
/src/shadowbox/infrastructure/logging.ts:
--------------------------------------------------------------------------------
1 | // Copyright 2018 The Outline Authors
2 | //
3 | // Licensed under the Apache License, Version 2.0 (the "License");
4 | // you may not use this file except in compliance with the License.
5 | // You may obtain a copy of the License at
6 | //
7 | // http://www.apache.org/licenses/LICENSE-2.0
8 | //
9 | // Unless required by applicable law or agreed to in writing, software
10 | // distributed under the License is distributed on an "AS IS" BASIS,
11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | // See the License for the specific language governing permissions and
13 | // limitations under the License.
14 |
15 | import * as path from 'path';
16 |
17 | interface Callsite {
18 | getLineNumber(): number;
19 | getFileName(): string;
20 | }
21 |
22 | // Returns the Callsite object of the caller.
23 | // This relies on the V8 stack trace API: https://github.com/v8/v8/wiki/Stack-Trace-API
24 | function getCallsite(): Callsite {
25 | const originalPrepareStackTrace = Error.prepareStackTrace;
26 | Error.prepareStackTrace = (_, stack) => {
27 | return stack;
28 | };
29 | const error = new Error();
30 | Error.captureStackTrace(error, getCallsite);
31 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
32 | const stack = error.stack as any as Callsite[];
33 | Error.prepareStackTrace = originalPrepareStackTrace;
34 | return stack[1];
35 | }
36 |
37 | // Possible values for the level prefix.
38 | type LevelPrefix = 'E' | 'W' | 'I' | 'D';
39 |
40 | // Formats the log message. Example:
41 | // I2018-08-16T16:46:21.577Z 167288 main.js:86] ...
42 | function makeLogMessage(level: LevelPrefix, callsite: Callsite, message: string): string {
43 | // This creates a string in the UTC timezone
44 | // See
45 | // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/toISOString
46 | const timeStr = new Date().toISOString();
47 | // TODO(alalama): preserve the source file structure in the webpack build so we can use
48 | // `callsite.getFileName()`.
49 | return `${level}${timeStr} ${process.pid} ${path.basename(
50 | callsite.getFileName() || __filename
51 | )}:${callsite.getLineNumber()}] ${message}`;
52 | }
53 |
54 | export enum LogLevel {
55 | // The order here is important, from less to more verbose.
56 | ERROR,
57 | WARNING,
58 | INFO,
59 | DEBUG,
60 | }
61 |
62 | const maxMsgLevel = logLevelFromEnvironment();
63 |
64 | function logLevelFromEnvironment(): LogLevel {
65 | if (process.env.LOG_LEVEL) {
66 | return parseLogLevel(process.env.LOG_LEVEL);
67 | }
68 | return LogLevel.INFO;
69 | }
70 |
71 | function parseLogLevel(levelStr: string) {
72 | switch(levelStr.toLowerCase()) {
73 | case "error":
74 | return LogLevel.ERROR;
75 | case "warning":
76 | case "warn":
77 | return LogLevel.WARNING;
78 | case "info":
79 | return LogLevel.INFO;
80 | case "debug":
81 | return LogLevel.DEBUG;
82 | default:
83 | throw new Error(`Invalid log level "${levelStr}"`);
84 | }
85 | }
86 |
87 | export function error(message: string) {
88 | if (LogLevel.ERROR <= maxMsgLevel) {
89 | console.error(makeLogMessage('E', getCallsite(), message));
90 | }
91 | }
92 |
93 | export function warn(message: string) {
94 | if (LogLevel.WARNING <= maxMsgLevel) {
95 | console.warn(makeLogMessage('W', getCallsite(), message));
96 | }
97 | }
98 |
99 | export function info(message: string) {
100 | if (LogLevel.INFO <= maxMsgLevel) {
101 | console.info(makeLogMessage('I', getCallsite(), message));
102 | }
103 | }
104 |
105 | export function debug(message: string) {
106 | if (LogLevel.DEBUG <= maxMsgLevel) {
107 | console.debug(makeLogMessage('D', getCallsite(), message));
108 | }
109 | }
110 |
--------------------------------------------------------------------------------
/src/shadowbox/infrastructure/prometheus_scraper.ts:
--------------------------------------------------------------------------------
1 | // Copyright 2018 The Outline Authors
2 | //
3 | // Licensed under the Apache License, Version 2.0 (the "License");
4 | // you may not use this file except in compliance with the License.
5 | // You may obtain a copy of the License at
6 | //
7 | // http://www.apache.org/licenses/LICENSE-2.0
8 | //
9 | // Unless required by applicable law or agreed to in writing, software
10 | // distributed under the License is distributed on an "AS IS" BASIS,
11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | // See the License for the specific language governing permissions and
13 | // limitations under the License.
14 |
15 | import * as child_process from 'child_process';
16 | import * as fs from 'fs';
17 | import * as http from 'http';
18 | import * as jsyaml from 'js-yaml';
19 | import * as mkdirp from 'mkdirp';
20 | import * as path from 'path';
21 |
22 | import * as logging from '../infrastructure/logging';
23 |
24 | /**
25 | * Represents a Unix timestamp in seconds.
26 | * @typedef {number} Timestamp
27 | */
28 | type Timestamp = number;
29 |
30 | /**
31 | * Represents a Prometheus metric's labels.
32 | * Each key in the object is a label name, and the corresponding value is the label's value.
33 | *
34 | * @typedef {Object} PrometheusMetric
35 | */
36 | export type PrometheusMetric = {[labelValue: string]: string};
37 |
38 | /**
39 | * Represents a Prometheus value, which is a tuple of a timestamp and a string value.
40 | * @typedef {[Timestamp, string]} PrometheusValue
41 | */
42 | export type PrometheusValue = [Timestamp, string];
43 |
44 | /**
45 | * Represents a Prometheus result, which can be a time series (values) or a single value.
46 | * @typedef {Object} PrometheusResult
47 | * @property {Object.} metric - Labels associated with the metric.
48 | * @property {Array} [values] - Time series data (for range queries).
49 | * @property {PrometheusValue} [value] - Single value (for instant queries).
50 | */
51 | export type PrometheusResult = {
52 | metric: PrometheusMetric;
53 | values?: PrometheusValue[];
54 | value?: PrometheusValue;
55 | };
56 |
57 | /**
58 | * Represents the data part of a Prometheus query result.
59 | * @interface QueryResultData
60 | */
61 | export interface QueryResultData {
62 | resultType: 'matrix' | 'vector' | 'scalar' | 'string';
63 | result: PrometheusResult[];
64 | }
65 |
66 | /**
67 | * Represents the full JSON response from a Prometheus query. This interface
68 | * is based on the Prometheus API documentation:
69 | * https://prometheus.io/docs/prometheus/latest/querying/api/
70 | * @interface QueryResult
71 | */
72 | interface QueryResult {
73 | status: 'success' | 'error';
74 | data: QueryResultData;
75 | errorType: string;
76 | error: string;
77 | }
78 |
79 | /**
80 | * Interface for a Prometheus client.
81 | * @interface PrometheusClient
82 | */
83 | export interface PrometheusClient {
84 | /**
85 | * Performs an instant query against the Prometheus API.
86 | * @function query
87 | * @param {string} query - The PromQL query string.
88 | * @returns {Promise} A Promise that resolves to the query result data.
89 | */
90 | query(query: string): Promise;
91 |
92 | /**
93 | * Performs a range query against the Prometheus API.
94 | * @function queryRange
95 | * @param {string} query - The PromQL query string.
96 | * @param {number} start - The start time for the query range.
97 | * @param {number} end - The end time for the query range.
98 | * @param {string} step - The step size for the query range (e.g., "1m", "5m"). This controls the resolution of the returned data.
99 | * @returns {Promise} A Promise that resolves to the query result data.
100 | */
101 | queryRange(query: string, start: number, end: number, step: string): Promise;
102 | }
103 |
104 | export class ApiPrometheusClient implements PrometheusClient {
105 | private readonly agent: http.Agent;
106 |
107 | constructor(private address: string) {
108 | this.agent = new http.Agent({ keepAlive: true });
109 | }
110 |
111 | private request(url: string): Promise {
112 | return new Promise((fulfill, reject) => {
113 | const options = {agent: this.agent};
114 | http
115 | .get(url, options, (response) => {
116 | if (response.statusCode < 200 || response.statusCode > 299) {
117 | reject(new Error(`Got error ${response.statusCode}`));
118 | response.resume();
119 | return;
120 | }
121 | let body = '';
122 | response.on('data', (data) => {
123 | body += data;
124 | });
125 | response.on('end', () => {
126 | const result = JSON.parse(body) as QueryResult;
127 | if (result.status !== 'success') {
128 | return reject(new Error(`Error ${result.errorType}: ${result.error}`));
129 | }
130 | fulfill(result.data);
131 | });
132 | })
133 | .on('error', (e) => {
134 | reject(new Error(`Failed to query prometheus API: ${e}`));
135 | });
136 | });
137 | }
138 |
139 | query(query: string): Promise {
140 | const url = `${this.address}/api/v1/query?query=${encodeURIComponent(query)}`;
141 | return this.request(url);
142 | }
143 |
144 | queryRange(query: string, start: number, end: number, step: string): Promise {
145 | const url = `${this.address}/api/v1/query_range?query=${encodeURIComponent(
146 | query
147 | )}&start=${start}&end=${end}&step=${step}`;
148 | return this.request(url);
149 | }
150 | }
151 |
152 | export async function startPrometheus(
153 | binaryFilename: string,
154 | configFilename: string,
155 | configJson: {},
156 | processArgs: string[],
157 | endpoint: string
158 | ) {
159 | await writePrometheusConfigToDisk(configFilename, configJson);
160 | await spawnPrometheusSubprocess(binaryFilename, processArgs, endpoint);
161 | }
162 |
163 | async function writePrometheusConfigToDisk(configFilename: string, configJson: {}) {
164 | await mkdirp.sync(path.dirname(configFilename));
165 | const ymlTxt = jsyaml.safeDump(configJson, {sortKeys: true});
166 | // Write the file asynchronously to prevent blocking the node thread.
167 | await new Promise((resolve, reject) => {
168 | fs.writeFile(configFilename, ymlTxt, 'utf-8', (err) => {
169 | if (err) {
170 | reject(err);
171 | } else {
172 | resolve();
173 | }
174 | });
175 | });
176 | }
177 |
178 | async function spawnPrometheusSubprocess(
179 | binaryFilename: string,
180 | processArgs: string[],
181 | prometheusEndpoint: string
182 | ): Promise {
183 | logging.info('======== Starting Prometheus ========');
184 | logging.info(`${binaryFilename} ${processArgs.map((a) => `"${a}"`).join(' ')}`);
185 | const runProcess = child_process.spawn(binaryFilename, processArgs);
186 | runProcess.on('error', (error) => {
187 | logging.error(`Error spawning Prometheus: ${error}`);
188 | });
189 | runProcess.on('exit', (code, signal) => {
190 | logging.error(`Prometheus has exited with error. Code: ${code}, Signal: ${signal}`);
191 | logging.error('Restarting Prometheus...');
192 | spawnPrometheusSubprocess(binaryFilename, processArgs, prometheusEndpoint);
193 | });
194 | // TODO(fortuna): Consider saving the output and expose it through the manager service.
195 | runProcess.stdout.pipe(process.stdout);
196 | runProcess.stderr.pipe(process.stderr);
197 | await waitForPrometheusReady(`${prometheusEndpoint}/api/v1/status/flags`);
198 | logging.info('Prometheus is ready!');
199 | return runProcess;
200 | }
201 |
202 | async function waitForPrometheusReady(prometheusEndpoint: string) {
203 | while (!(await isHttpEndpointHealthy(prometheusEndpoint))) {
204 | await new Promise((resolve) => setTimeout(resolve, 1000));
205 | }
206 | }
207 |
208 | function isHttpEndpointHealthy(endpoint: string): Promise {
209 | return new Promise((resolve, _) => {
210 | http
211 | .get(endpoint, (response) => {
212 | resolve(response.statusCode >= 200 && response.statusCode < 300);
213 | })
214 | .on('error', () => {
215 | // Prometheus is not ready yet.
216 | resolve(false);
217 | });
218 | });
219 | }
220 |
--------------------------------------------------------------------------------
/src/shadowbox/infrastructure/rollout.spec.ts:
--------------------------------------------------------------------------------
1 | // Copyright 2018 The Outline Authors
2 | //
3 | // Licensed under the Apache License, Version 2.0 (the "License");
4 | // you may not use this file except in compliance with the License.
5 | // You may obtain a copy of the License at
6 | //
7 | // http://www.apache.org/licenses/LICENSE-2.0
8 | //
9 | // Unless required by applicable law or agreed to in writing, software
10 | // distributed under the License is distributed on an "AS IS" BASIS,
11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | // See the License for the specific language governing permissions and
13 | // limitations under the License.
14 |
15 | import {RolloutTracker} from './rollout';
16 |
17 | describe('RolloutTracker', () => {
18 | describe('isRolloutEnabled', () => {
19 | it('throws on out of range percentages', () => {
20 | const tracker = new RolloutTracker('instance-id');
21 | expect(() => tracker.isRolloutEnabled('rollout-id', -1)).toThrowError();
22 | expect(() => tracker.isRolloutEnabled('rollout-id', 101)).toThrowError();
23 | });
24 | it('throws on fractional percentage', () => {
25 | const tracker = new RolloutTracker('instance-id');
26 | expect(() => tracker.isRolloutEnabled('rollout-id', 0.1)).toThrowError();
27 | expect(() => tracker.isRolloutEnabled('rollout-id', 50.1)).toThrowError();
28 | });
29 | it('returns false on 0%', () => {
30 | const tracker = new RolloutTracker('instance-id');
31 | expect(tracker.isRolloutEnabled('rollout-id', 0)).toBeFalsy();
32 | });
33 | it('returns true on 100%', () => {
34 | const tracker = new RolloutTracker('instance-id');
35 | expect(tracker.isRolloutEnabled('rollout-id', 100)).toBeTruthy();
36 | });
37 | it('returns true depending on percentage', () => {
38 | const tracker = new RolloutTracker('instance-id');
39 | expect(tracker.isRolloutEnabled('rollout-id', 9)).toBeFalsy();
40 | expect(tracker.isRolloutEnabled('rollout-id', 10)).toBeTruthy();
41 | });
42 | });
43 | describe('forceRollout', () => {
44 | it('forces rollout', () => {
45 | const tracker = new RolloutTracker('instance-id');
46 | tracker.forceRollout('rollout-id', true);
47 | expect(tracker.isRolloutEnabled('rollout-id', 0)).toBeTruthy();
48 | tracker.forceRollout('rollout-id', false);
49 | expect(tracker.isRolloutEnabled('rollout-id', 100)).toBeFalsy();
50 | });
51 | });
52 | });
53 |
--------------------------------------------------------------------------------
/src/shadowbox/infrastructure/rollout.ts:
--------------------------------------------------------------------------------
1 | // Copyright 2018 The Outline Authors
2 | //
3 | // Licensed under the Apache License, Version 2.0 (the "License");
4 | // you may not use this file except in compliance with the License.
5 | // You may obtain a copy of the License at
6 | //
7 | // http://www.apache.org/licenses/LICENSE-2.0
8 | //
9 | // Unless required by applicable law or agreed to in writing, software
10 | // distributed under the License is distributed on an "AS IS" BASIS,
11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | // See the License for the specific language governing permissions and
13 | // limitations under the License.
14 |
15 | import * as crypto from 'crypto';
16 |
17 | // Utility to help with partial rollouts of new features.
18 | export class RolloutTracker {
19 | private forcedRollouts = new Map();
20 |
21 | constructor(private instanceId: string) {}
22 |
23 | // Forces a rollout to be enabled or disabled.
24 | forceRollout(rolloutId: string, enabled: boolean) {
25 | this.forcedRollouts.set(rolloutId, enabled);
26 | }
27 |
28 | // Returns true if the given feature is rolled out for this instance.
29 | // `percentage` is between 0 and 100 and represents the percentage of
30 | // instances that should have the feature active.
31 | isRolloutEnabled(rolloutId: string, percentage: number) {
32 | if (this.forcedRollouts.has(rolloutId)) {
33 | return this.forcedRollouts.get(rolloutId);
34 | }
35 | if (percentage < 0 || percentage > 100) {
36 | throw new Error(`Expected 0 <= percentage <= 100. Found ${percentage}`);
37 | }
38 | if (Math.floor(percentage) !== percentage) {
39 | throw new Error(`Expected percentage to be an integer. Found ${percentage}`);
40 | }
41 | const hash = crypto.createHash('md5');
42 | hash.update(this.instanceId);
43 | hash.update(rolloutId);
44 | const buffer = hash.digest();
45 | return 100 * buffer[0] < percentage * 256;
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/src/shadowbox/infrastructure/text_file.ts:
--------------------------------------------------------------------------------
1 | // Copyright 2018 The Outline Authors
2 | //
3 | // Licensed under the Apache License, Version 2.0 (the "License");
4 | // you may not use this file except in compliance with the License.
5 | // You may obtain a copy of the License at
6 | //
7 | // http://www.apache.org/licenses/LICENSE-2.0
8 | //
9 | // Unless required by applicable law or agreed to in writing, software
10 | // distributed under the License is distributed on an "AS IS" BASIS,
11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | // See the License for the specific language governing permissions and
13 | // limitations under the License.
14 |
15 | export interface TextFile {
16 | readFileSync(): string;
17 | writeFileSync(text: string): void;
18 | }
19 |
--------------------------------------------------------------------------------
/src/shadowbox/integration_test/README.md:
--------------------------------------------------------------------------------
1 | # Outline Server Image Integration Test
2 |
3 | This folder contains the integration test for the Outline Server image.
4 |
5 | To build and test the image:
6 |
7 | ```sh
8 | task shadowbox:integration_test
9 | ```
10 |
11 | For development of the test, or to test a specific image, you may prefer calling the test directly, without the build step:
12 |
13 | ```sh
14 | ./task shadowbox:test_image IMAGE_NAME=quay.io/outline/shadowbox:stable
15 | ```
16 |
17 | If you prefer to use Podman instead of Docker, set the `DOCKER=podman` environment variable:
18 |
19 | ```sh
20 | DOCKER=podman task shadowbox:integration_test
21 | ```
22 |
--------------------------------------------------------------------------------
/src/shadowbox/integration_test/client/Dockerfile:
--------------------------------------------------------------------------------
1 | # Copyright 2018 The Outline Authors
2 | #
3 | # Licensed under the Apache License, Version 2.0 (the "License");
4 | # you may not use this file except in compliance with the License.
5 | # You may obtain a copy of the License at
6 | #
7 | # http://www.apache.org/licenses/LICENSE-2.0
8 | #
9 | # Unless required by applicable law or agreed to in writing, software
10 | # distributed under the License is distributed on an "AS IS" BASIS,
11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | # See the License for the specific language governing permissions and
13 | # limitations under the License.
14 |
15 | # Alpine 3.19 curl is using the c-ares resolver instead of the system resolver,
16 | # which caused DNS issues. Upgrade once the Alpine image includes the fix. See
17 | # https://github.com/Jigsaw-Code/outline-server/pull/1566.
18 | FROM docker.io/golang:1-alpine3.18
19 |
20 | # curl for fetching pages using the local proxy
21 | RUN apk add --no-cache curl git
22 | RUN go install github.com/shadowsocks/go-shadowsocks2@v0.1.5
23 |
24 | ENTRYPOINT [ "sh" ]
25 |
--------------------------------------------------------------------------------
/src/shadowbox/integration_test/target/Dockerfile:
--------------------------------------------------------------------------------
1 | # Copyright 2018 The Outline Authors
2 | #
3 | # Licensed under the Apache License, Version 2.0 (the "License");
4 | # you may not use this file except in compliance with the License.
5 | # You may obtain a copy of the License at
6 | #
7 | # http://www.apache.org/licenses/LICENSE-2.0
8 | #
9 | # Unless required by applicable law or agreed to in writing, software
10 | # distributed under the License is distributed on an "AS IS" BASIS,
11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | # See the License for the specific language governing permissions and
13 | # limitations under the License.
14 |
15 | # Pin to a known good signed image to avoid failures from the Docker notary service
16 | FROM gcr.io/distroless/python3@sha256:58087520b3c929fe77e1ef3fc95062dbe80bbda265e0e7966c4997c71a9636ea
17 | # The python SimpleHTTPServer doesn't quit with SIGTERM, so we use SIGKILL.
18 | STOPSIGNAL SIGKILL
19 | COPY index.html .
20 | ENTRYPOINT ["python", "-m", "http.server", "80"]
21 |
--------------------------------------------------------------------------------
/src/shadowbox/integration_test/target/index.html:
--------------------------------------------------------------------------------
1 |
16 |
17 |
18 | TARGET PAGE CONTENT
19 |
20 |
--------------------------------------------------------------------------------
/src/shadowbox/model/access_key.ts:
--------------------------------------------------------------------------------
1 | // Copyright 2018 The Outline Authors
2 | //
3 | // Licensed under the Apache License, Version 2.0 (the "License");
4 | // you may not use this file except in compliance with the License.
5 | // You may obtain a copy of the License at
6 | //
7 | // http://www.apache.org/licenses/LICENSE-2.0
8 | //
9 | // Unless required by applicable law or agreed to in writing, software
10 | // distributed under the License is distributed on an "AS IS" BASIS,
11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | // See the License for the specific language governing permissions and
13 | // limitations under the License.
14 |
15 | export type AccessKeyId = string;
16 |
17 | // Parameters needed to access a Shadowsocks proxy.
18 | export interface ProxyParams {
19 | // Hostname of the proxy
20 | readonly hostname: string;
21 | // Number of the port where the Shadowsocks service is running.
22 | readonly portNumber: number;
23 | // The Shadowsocks encryption method being used.
24 | readonly encryptionMethod: string;
25 | // The password for the encryption.
26 | readonly password: string;
27 | }
28 |
29 | // Data transfer allowance, measured in bytes. Must be a serializable JSON object.
30 | export interface DataLimit {
31 | readonly bytes: number;
32 | }
33 |
34 | // AccessKey is what admins work with. It gives ProxyParams a name and identity.
35 | export interface AccessKey {
36 | // The unique identifier for this access key.
37 | readonly id: AccessKeyId;
38 | // Admin-controlled, editable name for this access key.
39 | readonly name: string;
40 | // Parameters to access the proxy
41 | readonly proxyParams: ProxyParams;
42 | // Whether the access key has reached the data transfer limit.
43 | readonly reachedDataLimit: boolean;
44 | // The key's current data limit. If it exists, it overrides the server default data limit.
45 | readonly dataLimit?: DataLimit;
46 | }
47 |
48 | export interface AccessKeyCreateParams {
49 | // The unique identifier to give the access key. Throws if it exists.
50 | readonly id?: AccessKeyId;
51 | // The encryption method to use for the access key.
52 | readonly encryptionMethod?: string;
53 | // The name to give the access key.
54 | readonly name?: string;
55 | // The password to use for the access key.
56 | readonly password?: string;
57 | // The data transfer limit to apply to the access key.
58 | readonly dataLimit?: DataLimit;
59 | // The port number to use for the access key.
60 | readonly portNumber?: number;
61 | }
62 |
63 | export interface AccessKeyRepository {
64 | // Creates a new access key. Parameters are chosen automatically if not provided.
65 | createNewAccessKey(params?: AccessKeyCreateParams): Promise;
66 | // Removes the access key given its id. Throws on failure.
67 | removeAccessKey(id: AccessKeyId);
68 | // Returns the access key with the given id. Throws on failure.
69 | getAccessKey(id: AccessKeyId): AccessKey;
70 | // Lists all existing access keys
71 | listAccessKeys(): AccessKey[];
72 | // Changes the port for new access keys.
73 | setPortForNewAccessKeys(port: number): Promise;
74 | // Changes the hostname for access keys.
75 | setHostname(hostname: string): void;
76 | // Apply the specified update to the specified access key. Throws on failure.
77 | renameAccessKey(id: AccessKeyId, name: string): void;
78 | // Sets a data transfer limit for all access keys.
79 | setDefaultDataLimit(limit: DataLimit): void;
80 | // Removes the access key data transfer limit.
81 | removeDefaultDataLimit(): void;
82 | // Sets access key `id` to use the given custom data limit.
83 | setAccessKeyDataLimit(id: AccessKeyId, limit: DataLimit): void;
84 | // Removes the custom data limit from access key `id`.
85 | removeAccessKeyDataLimit(id: AccessKeyId): void;
86 | }
87 |
--------------------------------------------------------------------------------
/src/shadowbox/model/errors.ts:
--------------------------------------------------------------------------------
1 | // Copyright 2019 The Outline Authors
2 | //
3 | // Licensed under the Apache License, Version 2.0 (the "License");
4 | // you may not use this file except in compliance with the License.
5 | // You may obtain a copy of the License at
6 | //
7 | // http://www.apache.org/licenses/LICENSE-2.0
8 | //
9 | // Unless required by applicable law or agreed to in writing, software
10 | // distributed under the License is distributed on an "AS IS" BASIS,
11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | // See the License for the specific language governing permissions and
13 | // limitations under the License.
14 |
15 | import {AccessKeyId} from './access_key';
16 |
17 | // TODO(fortuna): Reuse CustomError from server_manager.
18 | class OutlineError extends Error {
19 | constructor(message: string) {
20 | super(message);
21 | // https://www.typescriptlang.org/docs/handbook/release-notes/typescript-2-2.html#support-for-newtarget
22 | Object.setPrototypeOf(this, new.target.prototype);
23 | }
24 | }
25 |
26 | export class InvalidPortNumber extends OutlineError {
27 | constructor(public port: number) {
28 | super(`Port ${port} is invalid: must be an integer in range [0, 65353]`);
29 | }
30 | }
31 |
32 | export class PortUnavailable extends OutlineError {
33 | constructor(public port: number) {
34 | super(`Port ${port} is unavailable`);
35 | }
36 | }
37 |
38 | export class AccessKeyNotFound extends OutlineError {
39 | constructor(accessKeyId?: AccessKeyId) {
40 | super(`Access key "${accessKeyId}" not found`);
41 | }
42 | }
43 |
44 | export class InvalidCipher extends OutlineError {
45 | constructor(public cipher: string) {
46 | super(`cipher "${cipher}" is not valid`);
47 | }
48 | }
49 |
50 | export class AccessKeyConflict extends OutlineError {
51 | constructor(accessKeyId?: AccessKeyId) {
52 | super(`Access key "${accessKeyId}" conflict`);
53 | }
54 | }
55 |
56 | export class PasswordConflict extends OutlineError {
57 | constructor(accessKeyId?: AccessKeyId) {
58 | super(
59 | `Access key ${accessKeyId} has the same password. Please specify a unique password for each access key`
60 | );
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/src/shadowbox/model/metrics.ts:
--------------------------------------------------------------------------------
1 | // Copyright 2018 The Outline Authors
2 | //
3 | // Licensed under the Apache License, Version 2.0 (the "License");
4 | // you may not use this file except in compliance with the License.
5 | // You may obtain a copy of the License at
6 | //
7 | // http://www.apache.org/licenses/LICENSE-2.0
8 | //
9 | // Unless required by applicable law or agreed to in writing, software
10 | // distributed under the License is distributed on an "AS IS" BASIS,
11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | // See the License for the specific language governing permissions and
13 | // limitations under the License.
14 |
15 | // Byte transfer metrics for a sliding timeframe, including both inbound and outbound.
16 | // TODO: this is copied at src/model/server.ts. Both copies should
17 | // be kept in sync, until we can find a way to share code between the web_app
18 | // and shadowbox.
19 | export interface DataUsageByUser {
20 | // The userId key should be of type AccessKeyId, however that results in the tsc
21 | // error TS1023: An index signature parameter type must be 'string' or 'number'.
22 | // See https://github.com/Microsoft/TypeScript/issues/2491
23 | // TODO: rename this to AccessKeyId in a backwards compatible way.
24 | bytesTransferredByUserId: {[userId: string]: number};
25 | }
26 |
27 | // Sliding time frame for measuring data utilization.
28 | export interface DataUsageTimeframe {
29 | hours: number;
30 | }
31 |
--------------------------------------------------------------------------------
/src/shadowbox/model/shadowsocks_server.ts:
--------------------------------------------------------------------------------
1 | // Copyright 2018 The Outline Authors
2 | //
3 | // Licensed under the Apache License, Version 2.0 (the "License");
4 | // you may not use this file except in compliance with the License.
5 | // You may obtain a copy of the License at
6 | //
7 | // http://www.apache.org/licenses/LICENSE-2.0
8 | //
9 | // Unless required by applicable law or agreed to in writing, software
10 | // distributed under the License is distributed on an "AS IS" BASIS,
11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | // See the License for the specific language governing permissions and
13 | // limitations under the License.
14 |
15 | // Parameters required to identify and authenticate connections to a Shadowsocks server.
16 | export interface ShadowsocksAccessKey {
17 | id: string;
18 | port: number;
19 | cipher: string;
20 | secret: string;
21 | }
22 |
23 | export interface ShadowsocksServer {
24 | // Updates the server to accept only the given access keys.
25 | update(keys: ShadowsocksAccessKey[]): Promise;
26 | }
27 |
--------------------------------------------------------------------------------
/src/shadowbox/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "outline-server",
3 | "private": true,
4 | "description": "Outline server",
5 | "main": "build/server/main.js",
6 | "author": "Outline",
7 | "license": "Apache",
8 | "__COMMENTS__": [
9 | "Using https:// for ShadowsocksConfig to avoid adding git in the Docker image"
10 | ],
11 | "dependencies": {
12 | "ip-regex": "^4.1.0",
13 | "js-yaml": "^3.12.0",
14 | "outline-shadowsocksconfig": "github:Jigsaw-Code/outline-shadowsocksconfig#v0.2.0",
15 | "prom-client": "^11.1.3",
16 | "randomstring": "^1.1.5",
17 | "restify": "^11.1.0",
18 | "restify-cors-middleware2": "^2.2.1",
19 | "restify-errors": "^8.0.2",
20 | "uuid": "^3.1.0"
21 | },
22 | "devDependencies": {
23 | "@types/js-yaml": "^3.11.2",
24 | "@types/node": "^12",
25 | "@types/randomstring": "^1.1.6",
26 | "@types/restify": "^8.4.2",
27 | "@types/restify-cors-middleware": "^1.0.1",
28 | "@types/tmp": "^0.2.1",
29 | "tmp": "^0.2.1",
30 | "ts-loader": "^9.5.0",
31 | "webpack": "^5.88.2",
32 | "webpack-cli": "^5.1.4"
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/src/shadowbox/scripts/make_test_certificate.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash -eu
2 | #
3 | # Copyright 2018 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.
16 |
17 | # Make a certificate for development purposes, and populate the
18 | # corresponding environment variables.
19 |
20 | readonly CERTIFICATE_NAME="$1/shadowbox-selfsigned-dev"
21 | export SB_CERTIFICATE_FILE="${CERTIFICATE_NAME}.crt"
22 | export SB_PRIVATE_KEY_FILE="${CERTIFICATE_NAME}.key"
23 | declare -a openssl_req_flags=(
24 | -x509
25 | -nodes
26 | -days 36500
27 | -newkey rsa:2048
28 | -subj '/CN=localhost'
29 | -keyout "${SB_PRIVATE_KEY_FILE}"
30 | -out "${SB_CERTIFICATE_FILE}"
31 | )
32 | openssl req "${openssl_req_flags[@]}"
33 |
--------------------------------------------------------------------------------
/src/shadowbox/scripts/update_mmdb.sh:
--------------------------------------------------------------------------------
1 | #!/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.
16 |
17 | # Download the IP-to-country and IP-to-ASN MMDB databases into the same location
18 | # used by Alpine's libmaxminddb package.
19 |
20 | # IP Geolocation by DB-IP (https://db-ip.com)
21 |
22 | # Note that this runs on BusyBox sh, which lacks bash features.
23 |
24 | TMPDIR="$(mktemp -d)"
25 | readonly TMPDIR
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
54 | fi
55 |
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 "$@"
68 |
--------------------------------------------------------------------------------
/src/shadowbox/server/mocks/mocks.ts:
--------------------------------------------------------------------------------
1 | // Copyright 2018 The Outline Authors
2 | //
3 | // Licensed under the Apache License, Version 2.0 (the "License");
4 | // you may not use this file except in compliance with the License.
5 | // You may obtain a copy of the License at
6 | //
7 | // http://www.apache.org/licenses/LICENSE-2.0
8 | //
9 | // Unless required by applicable law or agreed to in writing, software
10 | // distributed under the License is distributed on an "AS IS" BASIS,
11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | // See the License for the specific language governing permissions and
13 | // limitations under the License.
14 |
15 | import {PrometheusClient, QueryResultData} from '../../infrastructure/prometheus_scraper';
16 | import {ShadowsocksAccessKey, ShadowsocksServer} from '../../model/shadowsocks_server';
17 | import {TextFile} from '../../infrastructure/text_file';
18 |
19 | export class InMemoryFile implements TextFile {
20 | private savedText: string;
21 | constructor(private exists: boolean) {}
22 | readFileSync() {
23 | if (this.exists) {
24 | return this.savedText;
25 | } else {
26 | const err = new Error('no such file or directory');
27 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
28 | (err as any).code = 'ENOENT';
29 | throw err;
30 | }
31 | }
32 | writeFileSync(text: string) {
33 | this.savedText = text;
34 | this.exists = true;
35 | }
36 | }
37 |
38 | export class FakeShadowsocksServer implements ShadowsocksServer {
39 | private accessKeys: ShadowsocksAccessKey[] = [];
40 |
41 | update(keys: ShadowsocksAccessKey[]) {
42 | this.accessKeys = keys;
43 | return Promise.resolve();
44 | }
45 |
46 | getAccessKeys() {
47 | return this.accessKeys;
48 | }
49 | }
50 |
51 | export class FakePrometheusClient implements PrometheusClient {
52 | constructor(public bytesTransferredById: {[accessKeyId: string]: number}) {}
53 |
54 | async query(_query: string): Promise {
55 | const queryResultData = {result: []} as QueryResultData;
56 | for (const accessKeyId of Object.keys(this.bytesTransferredById)) {
57 | const bytesTransferred = this.bytesTransferredById[accessKeyId] || 0;
58 | queryResultData.result.push({
59 | metric: {access_key: accessKeyId},
60 | value: [Date.now() / 1000, `${bytesTransferred}`],
61 | });
62 | }
63 | return queryResultData;
64 | }
65 |
66 | queryRange(
67 | _query: string,
68 | _start: number,
69 | _end: number,
70 | _step: string
71 | ): Promise {
72 | throw new Error('unsupported');
73 | }
74 | }
75 |
--------------------------------------------------------------------------------
/src/shadowbox/server/outline_shadowsocks_server.ts:
--------------------------------------------------------------------------------
1 | // Copyright 2018 The Outline Authors
2 | //
3 | // Licensed under the Apache License, Version 2.0 (the "License");
4 | // you may not use this file except in compliance with the License.
5 | // You may obtain a copy of the License at
6 | //
7 | // http://www.apache.org/licenses/LICENSE-2.0
8 | //
9 | // Unless required by applicable law or agreed to in writing, software
10 | // distributed under the License is distributed on an "AS IS" BASIS,
11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | // See the License for the specific language governing permissions and
13 | // limitations under the License.
14 |
15 | import * as child_process from 'child_process';
16 | import * as jsyaml from 'js-yaml';
17 | import * as mkdirp from 'mkdirp';
18 | import * as path from 'path';
19 |
20 | import * as file from '../infrastructure/file';
21 | import * as logging from '../infrastructure/logging';
22 | import {ShadowsocksAccessKey, ShadowsocksServer} from '../model/shadowsocks_server';
23 |
24 | // Runs outline-ss-server.
25 | export class OutlineShadowsocksServer implements ShadowsocksServer {
26 | private ssProcess: child_process.ChildProcess;
27 | private ipCountryFilename?: string;
28 | private ipAsnFilename?: string;
29 | private isAsnMetricsEnabled = false;
30 | private isReplayProtectionEnabled = false;
31 |
32 | /**
33 | * @param binaryFilename The location for the outline-ss-server binary.
34 | * @param configFilename The location for the outline-ss-server config.
35 | * @param verbose Whether to run the server in verbose mode.
36 | * @param metricsLocation The location from where to serve the Prometheus data metrics.
37 | */
38 | constructor(
39 | private readonly binaryFilename: string,
40 | private readonly configFilename: string,
41 | private readonly verbose: boolean,
42 | private readonly metricsLocation: string
43 | ) {}
44 |
45 | /**
46 | * Configures the Shadowsocks Server with country data to annotate Prometheus data metrics.
47 | * @param ipCountryFilename The location of the ip-country.mmdb IP-to-country database file.
48 | */
49 | configureCountryMetrics(ipCountryFilename: string): OutlineShadowsocksServer {
50 | this.ipCountryFilename = ipCountryFilename;
51 | return this;
52 | }
53 |
54 | /**
55 | * Configures the Shadowsocks Server with ASN data to annotate Prometheus data metrics.
56 | * @param ipAsnFilename The location of the ip-asn.mmdb IP-to-ASN database file.
57 | */
58 | configureAsnMetrics(ipAsnFilename: string): OutlineShadowsocksServer {
59 | this.ipAsnFilename = ipAsnFilename;
60 | return this;
61 | }
62 |
63 | enableReplayProtection(): OutlineShadowsocksServer {
64 | this.isReplayProtectionEnabled = true;
65 | return this;
66 | }
67 |
68 | // Promise is resolved after the outline-ss-config config is updated and the SIGHUP sent.
69 | // Keys may not be active yet.
70 | // TODO(fortuna): Make promise resolve when keys are ready.
71 | update(keys: ShadowsocksAccessKey[]): Promise {
72 | return this.writeConfigFile(keys).then(() => {
73 | if (!this.ssProcess) {
74 | this.start();
75 | return Promise.resolve();
76 | } else {
77 | this.ssProcess.kill('SIGHUP');
78 | }
79 | });
80 | }
81 |
82 | private writeConfigFile(keys: ShadowsocksAccessKey[]): Promise {
83 | return new Promise((resolve, reject) => {
84 | const keysJson = {keys: [] as ShadowsocksAccessKey[]};
85 | for (const key of keys) {
86 | if (!isAeadCipher(key.cipher)) {
87 | logging.error(
88 | `Cipher ${key.cipher} for access key ${key.id} is not supported: use an AEAD cipher instead.`
89 | );
90 | continue;
91 | }
92 |
93 | keysJson.keys.push(key);
94 | }
95 |
96 | mkdirp.sync(path.dirname(this.configFilename));
97 |
98 | try {
99 | file.atomicWriteFileSync(this.configFilename, jsyaml.safeDump(keysJson, {sortKeys: true}));
100 | resolve();
101 | } catch (error) {
102 | reject(error);
103 | }
104 | });
105 | }
106 |
107 | private start() {
108 | const commandArguments = ['-config', this.configFilename, '-metrics', this.metricsLocation];
109 | if (this.ipCountryFilename) {
110 | commandArguments.push('-ip_country_db', this.ipCountryFilename);
111 | }
112 | if (this.ipAsnFilename) {
113 | commandArguments.push('-ip_asn_db', this.ipAsnFilename);
114 | }
115 | if (this.verbose) {
116 | commandArguments.push('-verbose');
117 | }
118 | if (this.isReplayProtectionEnabled) {
119 | commandArguments.push('--replay_history=10000');
120 | }
121 | logging.info('======== Starting Outline Shadowsocks Service ========');
122 | logging.info(`${this.binaryFilename} ${commandArguments.map((a) => `"${a}"`).join(' ')}`);
123 | this.ssProcess = child_process.spawn(this.binaryFilename, commandArguments);
124 | this.ssProcess.on('error', (error) => {
125 | logging.error(`Error spawning outline-ss-server: ${error}`);
126 | });
127 | this.ssProcess.on('exit', (code, signal) => {
128 | logging.info(`outline-ss-server has exited with error. Code: ${code}, Signal: ${signal}`);
129 | logging.info('Restarting');
130 | this.start();
131 | });
132 | // This exposes the outline-ss-server output on the docker logs.
133 | // TODO(fortuna): Consider saving the output and expose it through the manager service.
134 | this.ssProcess.stdout.pipe(process.stdout);
135 | this.ssProcess.stderr.pipe(process.stderr);
136 | }
137 | }
138 |
139 | // List of AEAD ciphers can be found at https://shadowsocks.org/en/spec/AEAD-Ciphers.html
140 | function isAeadCipher(cipherAlias: string) {
141 | cipherAlias = cipherAlias.toLowerCase();
142 | return cipherAlias.endsWith('gcm') || cipherAlias.endsWith('poly1305');
143 | }
144 |
--------------------------------------------------------------------------------
/src/shadowbox/server/server_config.ts:
--------------------------------------------------------------------------------
1 | // Copyright 2018 The Outline Authors
2 | //
3 | // Licensed under the Apache License, Version 2.0 (the "License");
4 | // you may not use this file except in compliance with the License.
5 | // You may obtain a copy of the License at
6 | //
7 | // http://www.apache.org/licenses/LICENSE-2.0
8 | //
9 | // Unless required by applicable law or agreed to in writing, software
10 | // distributed under the License is distributed on an "AS IS" BASIS,
11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | // See the License for the specific language governing permissions and
13 | // limitations under the License.
14 |
15 | import * as uuidv4 from 'uuid/v4';
16 |
17 | import * as json_config from '../infrastructure/json_config';
18 | import {DataLimit} from '../model/access_key';
19 |
20 | // Serialized format for the server config.
21 | // WARNING: Renaming fields will break backwards-compatibility.
22 | export interface ServerConfigJson {
23 | // The unique random identifier for this server. Used for shared metrics and staged rollouts.
24 | serverId?: string;
25 | // Whether metrics sharing is enabled.
26 | metricsEnabled?: boolean;
27 | // The name of this server, as shown in the Outline Manager.
28 | name?: string;
29 | // When this server was created. Shown in the Outline Manager and to trigger the metrics opt-in.
30 | createdTimestampMs?: number;
31 | // What port number should we use for new access keys?
32 | portForNewAccessKeys?: number;
33 | // Which staged rollouts we should force enabled or disabled.
34 | rollouts?: RolloutConfigJson[];
35 | // We don't serialize the shadowbox version, this is obtained dynamically from node.
36 | // Public proxy hostname.
37 | hostname?: string;
38 | // Default data transfer limit applied to all access keys.
39 | accessKeyDataLimit?: DataLimit;
40 |
41 | // Experimental configuration options that are expected to be short-lived.
42 | experimental?: {
43 | // Whether ASN metric annotation for Prometheus is enabled.
44 | asnMetricsEnabled?: boolean; // DEPRECATED
45 | };
46 | }
47 |
48 | // Serialized format for rollouts.
49 | // WARNING: Renaming fields will break backwards-compatibility.
50 | export interface RolloutConfigJson {
51 | // Unique identifier of the rollout.
52 | id: string;
53 | // Whether it's forced enabled or disabled. Omit for automatic behavior based on
54 | // hash(serverId, rolloutId).
55 | enabled: boolean;
56 | }
57 |
58 | export function readServerConfig(filename: string): json_config.JsonConfig {
59 | try {
60 | const config = json_config.loadFileConfig(filename);
61 | config.data().serverId = config.data().serverId || uuidv4();
62 | config.data().metricsEnabled = config.data().metricsEnabled || false;
63 | config.data().createdTimestampMs = config.data().createdTimestampMs || Date.now();
64 | config.data().hostname = config.data().hostname || process.env.SB_PUBLIC_IP;
65 | config.write();
66 | return config;
67 | } catch (error) {
68 | throw new Error(`Failed to read server config at ${filename}: ${error}`);
69 | }
70 | }
71 |
--------------------------------------------------------------------------------
/src/shadowbox/server/shared_metrics.ts:
--------------------------------------------------------------------------------
1 | // Copyright 2018 The Outline Authors
2 | //
3 | // Licensed under the Apache License, Version 2.0 (the "License");
4 | // you may not use this file except in compliance with the License.
5 | // You may obtain a copy of the License at
6 | //
7 | // http://www.apache.org/licenses/LICENSE-2.0
8 | //
9 | // Unless required by applicable law or agreed to in writing, software
10 | // distributed under the License is distributed on an "AS IS" BASIS,
11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | // See the License for the specific language governing permissions and
13 | // limitations under the License.
14 |
15 | import {Clock} from '../infrastructure/clock';
16 | import * as follow_redirects from '../infrastructure/follow_redirects';
17 | import {JsonConfig} from '../infrastructure/json_config';
18 | import * as logging from '../infrastructure/logging';
19 | import {PrometheusClient, QueryResultData} from '../infrastructure/prometheus_scraper';
20 | import * as version from './version';
21 | import {AccessKeyConfigJson} from './server_access_key';
22 |
23 | import {ServerConfigJson} from './server_config';
24 |
25 | const MS_PER_HOUR = 60 * 60 * 1000;
26 | const MS_PER_DAY = 24 * MS_PER_HOUR;
27 | const SANCTIONED_COUNTRIES = new Set(['CU', 'KP', 'SY']);
28 |
29 | export interface ReportedUsage {
30 | country: string;
31 | asn?: number;
32 | inboundBytes: number;
33 | tunnelTimeSec: number;
34 | }
35 |
36 | // JSON format for the published report.
37 | // Field renames will break backwards-compatibility.
38 | export interface HourlyServerMetricsReportJson {
39 | serverId: string;
40 | startUtcMs: number;
41 | endUtcMs: number;
42 | userReports: HourlyUserMetricsReportJson[];
43 | }
44 |
45 | // JSON format for the published report.
46 | // Field renames will break backwards-compatibility.
47 | export interface HourlyUserMetricsReportJson {
48 | countries: string[];
49 | asn?: number;
50 | bytesTransferred: number;
51 | tunnelTimeSec: number;
52 | }
53 |
54 | // JSON format for the feature metrics report.
55 | // Field renames will break backwards-compatibility.
56 | export interface DailyFeatureMetricsReportJson {
57 | serverId: string;
58 | serverVersion: string;
59 | timestampUtcMs: number;
60 | dataLimit: DailyDataLimitMetricsReportJson;
61 | }
62 |
63 | // JSON format for the data limit feature metrics report.
64 | // Field renames will break backwards-compatibility.
65 | export interface DailyDataLimitMetricsReportJson {
66 | enabled: boolean;
67 | perKeyLimitCount?: number;
68 | }
69 |
70 | export interface SharedMetricsPublisher {
71 | startSharing();
72 | stopSharing();
73 | isSharingEnabled();
74 | }
75 |
76 | export interface UsageMetrics {
77 | getReportedUsage(): Promise;
78 | reset();
79 | }
80 |
81 | // Reads data usage metrics from Prometheus.
82 | export class PrometheusUsageMetrics implements UsageMetrics {
83 | private resetTimeMs: number = Date.now();
84 |
85 | constructor(private prometheusClient: PrometheusClient) {}
86 |
87 | async getReportedUsage(): Promise {
88 | const timeDeltaSecs = Math.round((Date.now() - this.resetTimeMs) / 1000);
89 |
90 | const usage = new Map();
91 | const processResults = (
92 | data: QueryResultData,
93 | setValue: (entry: ReportedUsage, value: string) => void
94 | ) => {
95 | for (const result of data.result) {
96 | const country = result.metric['location'] || '';
97 | const asn = result.metric['asn'] ? Number(result.metric['asn']) : undefined;
98 | const key = `${country}-${asn}`;
99 | const entry = usage.get(key) || {
100 | country,
101 | asn,
102 | inboundBytes: 0,
103 | tunnelTimeSec: 0,
104 | };
105 | setValue(entry, result.value[1]);
106 | if (!usage.has(key)) {
107 | usage.set(key, entry);
108 | }
109 | }
110 | };
111 |
112 | // Query and process inbound data bytes by country+ASN.
113 | const dataBytesQueryResponse = await this.prometheusClient.query(
114 | `sum(increase(shadowsocks_data_bytes_per_location{dir=~"p>t|p {
117 | entry.inboundBytes = Math.round(parseFloat(value));
118 | });
119 |
120 | // Query and process tunneltime by country+ASN.
121 | const tunnelTimeQueryResponse = await this.prometheusClient.query(
122 | `sum(increase(shadowsocks_tunnel_time_seconds_per_location[${timeDeltaSecs}s])) by (location, asn)`
123 | );
124 | processResults(tunnelTimeQueryResponse, (entry, value) => {
125 | entry.tunnelTimeSec = Math.round(parseFloat(value));
126 | });
127 |
128 | return Array.from(usage.values());
129 | }
130 |
131 | reset() {
132 | this.resetTimeMs = Date.now();
133 | }
134 | }
135 |
136 | export interface MetricsCollectorClient {
137 | collectServerUsageMetrics(reportJson: HourlyServerMetricsReportJson): Promise;
138 | collectFeatureMetrics(reportJson: DailyFeatureMetricsReportJson): Promise;
139 | }
140 |
141 | export class RestMetricsCollectorClient implements MetricsCollectorClient {
142 | constructor(private serviceUrl: string) {}
143 |
144 | collectServerUsageMetrics(reportJson: HourlyServerMetricsReportJson): Promise {
145 | return this.postMetrics('/connections', JSON.stringify(reportJson));
146 | }
147 |
148 | collectFeatureMetrics(reportJson: DailyFeatureMetricsReportJson): Promise {
149 | return this.postMetrics('/features', JSON.stringify(reportJson));
150 | }
151 |
152 | private async postMetrics(urlPath: string, reportJson: string): Promise {
153 | const options = {
154 | headers: {'Content-Type': 'application/json'},
155 | method: 'POST',
156 | body: reportJson,
157 | };
158 | const url = `${this.serviceUrl}${urlPath}`;
159 | logging.debug(`Posting metrics to ${url} with options ${JSON.stringify(options)}`);
160 | try {
161 | const response = await follow_redirects.requestFollowRedirectsWithSameMethodAndBody(
162 | url,
163 | options
164 | );
165 | if (!response.ok) {
166 | throw new Error(`Got status ${response.status}`);
167 | }
168 | } catch (e) {
169 | throw new Error(`Failed to post to metrics server: ${e}`);
170 | }
171 | }
172 | }
173 |
174 | // Keeps track of the connection metrics per user, since the startDatetime.
175 | // This is reported to the Outline team if the admin opts-in.
176 | export class OutlineSharedMetricsPublisher implements SharedMetricsPublisher {
177 | // Time at which we started recording connection metrics.
178 | private reportStartTimestampMs: number;
179 |
180 | // serverConfig: where the enabled/disable setting is persisted
181 | // keyConfig: where access keys are persisted
182 | // usageMetrics: where we get the metrics from
183 | // metricsUrl: where to post the metrics
184 | constructor(
185 | private clock: Clock,
186 | private serverConfig: JsonConfig,
187 | private keyConfig: JsonConfig,
188 | usageMetrics: UsageMetrics,
189 | private metricsCollector: MetricsCollectorClient
190 | ) {
191 | // Start timer
192 | this.reportStartTimestampMs = this.clock.now();
193 |
194 | this.clock.setInterval(async () => {
195 | if (!this.isSharingEnabled()) {
196 | return;
197 | }
198 | try {
199 | await this.reportServerUsageMetrics(await usageMetrics.getReportedUsage());
200 | usageMetrics.reset();
201 | } catch (err) {
202 | logging.error(`Failed to report server usage metrics: ${err}`);
203 | }
204 | }, MS_PER_HOUR);
205 | // TODO(fortuna): also trigger report on shutdown, so data loss is minimized.
206 |
207 | this.clock.setInterval(async () => {
208 | if (!this.isSharingEnabled()) {
209 | return;
210 | }
211 | try {
212 | await this.reportFeatureMetrics();
213 | } catch (err) {
214 | logging.error(`Failed to report feature metrics: ${err}`);
215 | }
216 | }, MS_PER_DAY);
217 | }
218 |
219 | startSharing() {
220 | this.serverConfig.data().metricsEnabled = true;
221 | this.serverConfig.write();
222 | }
223 |
224 | stopSharing() {
225 | this.serverConfig.data().metricsEnabled = false;
226 | this.serverConfig.write();
227 | }
228 |
229 | isSharingEnabled(): boolean {
230 | return this.serverConfig.data().metricsEnabled || false;
231 | }
232 |
233 | private async reportServerUsageMetrics(locationUsageMetrics: ReportedUsage[]): Promise {
234 | const reportEndTimestampMs = this.clock.now();
235 |
236 | const userReports: HourlyUserMetricsReportJson[] = [];
237 | for (const locationUsage of locationUsageMetrics) {
238 | if (locationUsage.inboundBytes === 0 && locationUsage.tunnelTimeSec === 0) {
239 | continue;
240 | }
241 | if (isSanctionedCountry(locationUsage.country)) {
242 | continue;
243 | }
244 | // Make sure to always set a country, which is required by the metrics server validation.
245 | // It's used to differentiate the row from the legacy key usage rows.
246 | const country = locationUsage.country || 'ZZ';
247 | const report: HourlyUserMetricsReportJson = {
248 | countries: [country],
249 | bytesTransferred: locationUsage.inboundBytes,
250 | tunnelTimeSec: locationUsage.tunnelTimeSec,
251 | };
252 | if (locationUsage.asn) {
253 | report.asn = locationUsage.asn;
254 | }
255 | userReports.push(report);
256 | }
257 | const report = {
258 | serverId: this.serverConfig.data().serverId,
259 | startUtcMs: this.reportStartTimestampMs,
260 | endUtcMs: reportEndTimestampMs,
261 | userReports,
262 | } as HourlyServerMetricsReportJson;
263 |
264 | this.reportStartTimestampMs = reportEndTimestampMs;
265 | if (userReports.length === 0) {
266 | return;
267 | }
268 | await this.metricsCollector.collectServerUsageMetrics(report);
269 | }
270 |
271 | private async reportFeatureMetrics(): Promise {
272 | const keys = this.keyConfig.data().accessKeys;
273 | const featureMetricsReport = {
274 | serverId: this.serverConfig.data().serverId,
275 | serverVersion: version.getPackageVersion(),
276 | timestampUtcMs: this.clock.now(),
277 | dataLimit: {
278 | enabled: !!this.serverConfig.data().accessKeyDataLimit,
279 | perKeyLimitCount: keys.filter((key) => !!key.dataLimit).length,
280 | },
281 | };
282 | await this.metricsCollector.collectFeatureMetrics(featureMetricsReport);
283 | }
284 | }
285 |
286 | function isSanctionedCountry(country: string) {
287 | return SANCTIONED_COUNTRIES.has(country);
288 | }
289 |
--------------------------------------------------------------------------------
/src/shadowbox/server/version.ts:
--------------------------------------------------------------------------------
1 | // Copyright 2024 The Outline Authors
2 | //
3 | // Licensed under the Apache License, Version 2.0 (the "License");
4 | // you may not use this file except in compliance with the License.
5 | // You may obtain a copy of the License at
6 | //
7 | // http://www.apache.org/licenses/LICENSE-2.0
8 | //
9 | // Unless required by applicable law or agreed to in writing, software
10 | // distributed under the License is distributed on an "AS IS" BASIS,
11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | // See the License for the specific language governing permissions and
13 | // limitations under the License.
14 |
15 | // Injected by WebPack with webpack.DefinePlugin.
16 | declare const __VERSION__: string;
17 |
18 | export function getPackageVersion(): string {
19 | try {
20 | return __VERSION__;
21 | } catch {
22 | // Catch the ReferenceError if __VERSION__ is not injected.
23 | return "dev"
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/src/shadowbox/shadowbox_config.json:
--------------------------------------------------------------------------------
1 | {"users": []}
2 |
--------------------------------------------------------------------------------
/src/shadowbox/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es2015",
4 | "removeComments": false,
5 | "noImplicitAny": false,
6 | "noImplicitThis": true,
7 | "module": "commonjs",
8 | "rootDir": ".",
9 | "resolveJsonModule": true,
10 | "sourceMap": true,
11 | "skipLibCheck": true
12 | },
13 | "include": ["server/main.ts", "**/*.spec.ts", "types/**/*.d.ts"],
14 | "exclude": ["build", "node_modules"]
15 | }
16 |
--------------------------------------------------------------------------------
/src/shadowbox/types/node.d.ts:
--------------------------------------------------------------------------------
1 | // Copyright 2018 The Outline Authors
2 | //
3 | // Licensed under the Apache License, Version 2.0 (the "License");
4 | // you may not use this file except in compliance with the License.
5 | // You may obtain a copy of the License at
6 | //
7 | // http://www.apache.org/licenses/LICENSE-2.0
8 | //
9 | // Unless required by applicable law or agreed to in writing, software
10 | // distributed under the License is distributed on an "AS IS" BASIS,
11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | // See the License for the specific language governing permissions and
13 | // limitations under the License.
14 |
15 | // Definitions missing from @types/node.
16 |
17 | // Reference: https://nodejs.org/api/dns.html
18 | declare module 'dns' {
19 | export function getServers(): string[];
20 | }
21 |
22 | // https://nodejs.org/dist/latest-v8.x/docs/api/child_process.html#child_process_child_process_exec_command_options_callback
23 | declare module 'child_process' {
24 | export interface ExecError {
25 | code: number;
26 | }
27 | export function exec(
28 | command: string,
29 | callback?: (error: ExecError | undefined, stdout: string, stderr: string) => void
30 | ): ChildProcess;
31 | }
32 |
--------------------------------------------------------------------------------
/src/shadowbox/webpack.config.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/no-var-requires */
2 | // Copyright 2020 The Outline Authors
3 | //
4 | // Licensed under the Apache License, Version 2.0 (the "License");
5 | // you may not use this file except in compliance with the License.
6 | // You may obtain a copy of the License at
7 | //
8 | // http://www.apache.org/licenses/LICENSE-2.0
9 | //
10 | // Unless required by applicable law or agreed to in writing, software
11 | // distributed under the License is distributed on an "AS IS" BASIS,
12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | // See the License for the specific language governing permissions and
14 | // limitations under the License.
15 |
16 | const path = require('path');
17 | const webpack = require('webpack');
18 |
19 | const config = {
20 | mode: 'production',
21 | entry: path.resolve(__dirname, './server/main.ts'),
22 | target: 'node',
23 | output: {
24 | filename: 'main.js',
25 | path: path.resolve(__dirname, '../../build/shadowbox/app'),
26 | },
27 | module: {rules: [{test: /\.ts(x)?$/, use: 'ts-loader'}]},
28 | node: {
29 | // Use the regular node behavior, the directory name of the output file when run.
30 | __dirname: false,
31 | },
32 | plugins: [
33 | // Used by server/version.ts.
34 | process.env.SB_VERSION ? new webpack.DefinePlugin({'__VERSION__': JSON.stringify(process.env.SB_VERSION)}): undefined,
35 | // WORKAROUND: some of our (transitive) dependencies use node-gently, which hijacks `require`.
36 | // Setting global.GENTLY to false makes these dependencies use standard require.
37 | new webpack.DefinePlugin({'global.GENTLY': false}),
38 | ],
39 | resolve: {extensions: ['.tsx', '.ts', '.js']},
40 | };
41 |
42 | module.exports = config;
43 |
--------------------------------------------------------------------------------
/third_party/Taskfile.yml:
--------------------------------------------------------------------------------
1 | version: '3'
2 |
3 | requires:
4 | vars: [OUTPUT_BASE]
5 |
6 | tasks:
7 | prometheus:debug:
8 | cmds:
9 | - echo {{.GOOS}}
10 |
11 | prometheus:download-*-*:
12 | desc: Download and extract prometheus binary
13 | vars:
14 | VERSION: '2.53.4'
15 | GOOS: '{{index .MATCH 0}}'
16 | GOARCH: '{{index .MATCH 1}}'
17 | TEMPFILE: {sh: mktemp}
18 | SHA256: '{{printf "%v/%v" .GOOS .GOARCH | get
19 | (dict
20 | "linux/amd64" "b8b497c4610d1b93208252b60c8f20f6b2e78596ae8df43397a2e805aa53d475"
21 | "linux/arm64" "ec7236ecea7154d0bfe142921708b1ae7b5e921e100e0ee85ab92b7c444357e0"
22 | "darwin/amd64" "10066a1aa21c4ddb8d5e61c31b52e898d8ac42c7e99fd757e2fc4b6c20b8075f"
23 | "darwin/arm64" "cb3e638d8e9b4b27a6aa1f45a4faa3741b548aac67d4649aea7a2fad3c09f0a1"
24 | )
25 | }}'
26 | TARGET_DIR: '{{joinPath .OUTPUT_BASE "prometheus" .GOOS .GOARCH}}'
27 | TARGET: '{{joinPath .TARGET_DIR "prometheus"}}'
28 | generates: ['{{.TARGET}}']
29 | sources: ['Taskfile.yml']
30 | preconditions:
31 | - {sh: "[[ '{{.GOOS}}' =~ 'linux|darwin' ]]", msg: "invalid GOOS {{.GOOS}}"}
32 | - {sh: "[[ '{{.GOARCH}}' =~ 'amd64|arm64' ]]", msg: "invalid GOARCH {{.GOARCH}}"}
33 | cmds:
34 | - node '{{joinPath .ROOT_DIR "src/build/download_file.mjs"}}' --url='https://github.com/prometheus/prometheus/releases/download/v{{.VERSION}}/prometheus-{{.VERSION}}.{{.GOOS}}-{{.GOARCH}}.tar.gz' --out='{{.TEMPFILE}}' --sha256='{{.SHA256}}'
35 | - defer: rm -f '{{.TEMPFILE}}'
36 | - mkdir -p '{{.TARGET_DIR}}'
37 | - tar -zx -f '{{.TEMPFILE}}' --strip-components=1 -C '{{.TARGET_DIR}}' 'prometheus-{{.VERSION}}.{{.GOOS}}-{{.GOARCH}}/prometheus'
38 | - chmod +x '{{.TARGET}}'
39 |
40 | prometheus:copy-*-*:
41 | desc: Copy prometheus binary to target directory
42 | requires:
43 | vars: [TARGET_DIR]
44 | vars:
45 | GOOS: '{{index .MATCH 0}}'
46 | GOARCH: '{{index .MATCH 1}}'
47 | cmds:
48 | - task: prometheus:download-{{.GOOS}}-{{.GOARCH}}
49 | - mkdir -p '{{.TARGET_DIR}}'
50 | - cp -R '{{joinPath .OUTPUT_BASE "prometheus" .GOOS .GOARCH}}'/* '{{.TARGET_DIR}}/'
51 |
--------------------------------------------------------------------------------
/third_party/prometheus/.gitignore:
--------------------------------------------------------------------------------
1 | /bin/
2 |
--------------------------------------------------------------------------------
/third_party/prometheus/METADATA:
--------------------------------------------------------------------------------
1 | name: "prometheus"
2 | description:
3 | "Prometheus is an open-source systems monitoring and alerting toolkit "
4 | "originally built at SoundCloud."
5 |
6 | third_party {
7 | url {
8 | type: HOMEPAGE
9 | value: "https://prometheus.io/"
10 | }
11 | url {
12 | type: ARCHIVE
13 | value: "https://github.com/prometheus/prometheus/releases/download/v2.53.4/prometheus-2.53.4.linux-amd64.tar.gz"
14 | }
15 | url {
16 | type: ARCHIVE
17 | value: "https://github.com/prometheus/prometheus/releases/download/v2.53.4/prometheus-2.53.4.linux-arm64.tar.gz"
18 | }
19 | url {
20 | type: ARCHIVE
21 | value: "https://github.com/prometheus/prometheus/releases/download/v2.53.4/prometheus-2.53.4.darwin-amd64.tar.gz"
22 | }
23 | url {
24 | type: ARCHIVE
25 | value: "https://github.com/prometheus/prometheus/releases/download/v2.53.4/prometheus-2.53.4.darwin-arm64.tar.gz"
26 | }
27 | version: "2.53.4"
28 | last_upgrade_date { year: 2025 month: 3 day: 20 }
29 | license_type: PERMISSIVE
30 | }
31 |
--------------------------------------------------------------------------------
/third_party/prometheus/NOTICE:
--------------------------------------------------------------------------------
1 | The Prometheus systems and service monitoring server
2 | Copyright 2012-2015 The Prometheus Authors
3 |
4 | This product includes software developed at
5 | SoundCloud Ltd. (https://soundcloud.com/).
6 |
7 |
8 | The following components are included in this product:
9 |
10 | Bootstrap
11 | https://getbootstrap.com
12 | Copyright 2011-2014 Twitter, Inc.
13 | Licensed under the MIT License
14 |
15 | bootstrap3-typeahead.js
16 | https://github.com/bassjobsen/Bootstrap-3-Typeahead
17 | Original written by @mdo and @fat
18 | Copyright 2014 Bass Jobsen @bassjobsen
19 | Licensed under the Apache License, Version 2.0
20 |
21 | fuzzy
22 | https://github.com/mattyork/fuzzy
23 | Original written by @mattyork
24 | Copyright 2012 Matt York
25 | Licensed under the MIT License
26 |
27 | bootstrap-datetimepicker.js
28 | https://github.com/Eonasdan/bootstrap-datetimepicker
29 | Copyright 2015 Jonathan Peterson (@Eonasdan)
30 | Licensed under the MIT License
31 |
32 | moment.js
33 | https://github.com/moment/moment/
34 | Copyright JS Foundation and other contributors
35 | Licensed under the MIT License
36 |
37 | Rickshaw
38 | https://github.com/shutterstock/rickshaw
39 | Copyright 2011-2014 by Shutterstock Images, LLC
40 | See https://github.com/shutterstock/rickshaw/blob/master/LICENSE for license details
41 |
42 | mustache.js
43 | https://github.com/janl/mustache.js
44 | Copyright 2009 Chris Wanstrath (Ruby)
45 | Copyright 2010-2014 Jan Lehnardt (JavaScript)
46 | Copyright 2010-2015 The mustache.js community
47 | Licensed under the MIT License
48 |
49 | jQuery
50 | https://jquery.org
51 | Copyright jQuery Foundation and other contributors
52 | Licensed under the MIT License
53 |
54 | Protocol Buffers for Go with Gadgets
55 | https://github.com/gogo/protobuf/
56 | Copyright (c) 2013, The GoGo Authors.
57 | See source code for license details.
58 |
59 | Go support for leveled logs, analogous to
60 | https://code.google.com/p/google-glog/
61 | Copyright 2013 Google Inc.
62 | Licensed under the Apache License, Version 2.0
63 |
64 | Support for streaming Protocol Buffer messages for the Go language (golang).
65 | https://github.com/matttproud/golang_protobuf_extensions
66 | Copyright 2013 Matt T. Proud
67 | Licensed under the Apache License, Version 2.0
68 |
69 | DNS library in Go
70 | https://miek.nl/2014/august/16/go-dns-package/
71 | Copyright 2009 The Go Authors, 2011 Miek Gieben
72 | See https://github.com/miekg/dns/blob/master/LICENSE for license details.
73 |
74 | LevelDB key/value database in Go
75 | https://github.com/syndtr/goleveldb
76 | Copyright 2012 Suryandaru Triandana
77 | See https://github.com/syndtr/goleveldb/blob/master/LICENSE for license details.
78 |
79 | gosnappy - a fork of code.google.com/p/snappy-go
80 | https://github.com/syndtr/gosnappy
81 | Copyright 2011 The Snappy-Go Authors
82 | See https://github.com/syndtr/gosnappy/blob/master/LICENSE for license details.
83 |
84 | go-zookeeper - Native ZooKeeper client for Go
85 | https://github.com/samuel/go-zookeeper
86 | Copyright (c) 2013, Samuel Stauffer
87 | See https://github.com/samuel/go-zookeeper/blob/master/LICENSE for license details.
88 |
89 | We also use code from a large number of npm packages. For details, see:
90 | - https://github.com/prometheus/prometheus/blob/master/web/ui/react-app/package.json
91 | - https://github.com/prometheus/prometheus/blob/master/web/ui/react-app/package-lock.json
92 | - The individual package licenses as copied from the node_modules directory can be found in
93 | the npm_licenses.tar.bz2 archive in release tarballs and Docker images.
94 |
--------------------------------------------------------------------------------
/third_party/shellcheck/README.md:
--------------------------------------------------------------------------------
1 | # Outline Shellcheck Wrapper
2 |
3 | This directory is used to lint our scripts using [Shellcheck](https://www.shellcheck.net/). To ensure consistency across developer systems, the included script
4 |
5 | - Attempts to identify the developer's OS (Linux, macOS, or Windows)
6 | - Downloads a pinned version of Shellcheck into `./download`
7 | - Checks the archive hash
8 | - Extracts the executable
9 | - Runs the executable
10 |
11 | The executable is cached on the developer's system after the first download. To clear the cache, run `rm download` (or `npm run clean` in the repository root).
12 |
--------------------------------------------------------------------------------
/third_party/shellcheck/hashes.sha256:
--------------------------------------------------------------------------------
1 | b080c3b659f7286e27004aa33759664d91e15ef2498ac709a452445d47e3ac23 shellcheck-v0.7.1.darwin.x86_64.tar.xz
2 | 64f17152d96d7ec261ad3086ed42d18232fcb65148b44571b564d688269d36c8 shellcheck-v0.7.1.linux.x86_64.tar.xz
3 | 1763f8f4a639d39e341798c7787d360ed79c3d68a1cdbad0549c9c0767a75e98 shellcheck-v0.7.1.zip
4 |
--------------------------------------------------------------------------------
/third_party/shellcheck/run.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash -eu
2 | #
3 | # Copyright 2021 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.
16 |
17 | readonly VERSION='v0.7.1'
18 |
19 | # The relative location of this script.
20 | DOWNLOAD_DIR="$(dirname "$0")/download"
21 | readonly DOWNLOAD_DIR
22 |
23 | declare file="shellcheck-${VERSION}" # Name of the file to download
24 | declare cmd="${DOWNLOAD_DIR}/shellcheck-${VERSION}" # Path to the executable
25 | declare sha256='' # SHA256 checksum
26 | case "$(uname -s)" in
27 | Linux) file+='.linux.x86_64.tar.xz'; cmd+='/shellcheck'; sha256='64f17152d96d7ec261ad3086ed42d18232fcb65148b44571b564d688269d36c8';;
28 | Darwin) file+='.darwin.x86_64.tar.xz'; cmd+='/shellcheck'; sha256='b080c3b659f7286e27004aa33759664d91e15ef2498ac709a452445d47e3ac23' ;;
29 | *) file+='.zip'; cmd+='.exe'; sha256='1763f8f4a639d39e341798c7787d360ed79c3d68a1cdbad0549c9c0767a75e98';; # Presume Windows/Cygwin
30 | esac
31 | readonly file cmd
32 |
33 | if [[ ! -s "${cmd}" ]]; then
34 | mkdir -p "${DOWNLOAD_DIR}"
35 |
36 | node "$(dirname "$0")/../../src/build/download_file.mjs" --url="https://github.com/koalaman/shellcheck/releases/download/${VERSION}/${file}" --out="${DOWNLOAD_DIR}/${file}" --sha256="${sha256}"
37 |
38 | pushd "${DOWNLOAD_DIR}"
39 | if [[ "${file}" == *'.tar.xz' ]]; then
40 | tar xf "${file}"
41 | else
42 | unzip "${file}"
43 | fi
44 | popd > /dev/null
45 | chmod +x "${cmd}"
46 | fi
47 |
48 | "${cmd}" "$@"
49 |
--------------------------------------------------------------------------------
/tools.go:
--------------------------------------------------------------------------------
1 | // Copyright 2024 The Outline Authors
2 | //
3 | // Licensed under the Apache License, Version 2.0 (the "License");
4 | // you may not use this file except in compliance with the License.
5 | // You may obtain a copy of the License at
6 | //
7 | // http://www.apache.org/licenses/LICENSE-2.0
8 | //
9 | // Unless required by applicable law or agreed to in writing, software
10 | // distributed under the License is distributed on an "AS IS" BASIS,
11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | // See the License for the specific language governing permissions and
13 | // limitations under the License.
14 |
15 | //go:build tools
16 | // +build tools
17 |
18 | // See https://github.com/golang/go/wiki/Modules#how-can-i-track-tool-dependencies-for-a-module
19 | // and https://github.com/go-modules-by-example/index/blob/master/010_tools/README.md
20 |
21 | package tools
22 |
23 | import (
24 | _ "github.com/Jigsaw-Code/outline-ss-server/cmd/outline-ss-server"
25 | _ "github.com/go-task/task/v3/cmd/task"
26 | _ "github.com/google/addlicense"
27 | )
28 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es2015",
4 | "removeComments": false,
5 | "noImplicitAny": true,
6 | "noImplicitThis": true,
7 | "moduleResolution": "Node",
8 | "sourceMap": true,
9 | "experimentalDecorators": true,
10 | "allowJs": true,
11 | "resolveJsonModule": true,
12 | "noUnusedLocals": true,
13 | "skipLibCheck": true
14 | }
15 | }
16 |
--------------------------------------------------------------------------------