diff --git a/.github/workflows/build-shadowbox.yml b/.github/workflows/build-shadowbox.yml new file mode 100644 index 000000000..f1d7d12f9 --- /dev/null +++ b/.github/workflows/build-shadowbox.yml @@ -0,0 +1,141 @@ +# Copyright 2024 The Outline Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +name: Build and Push Shadowbox Docker Image + +on: + push: + branches: + - '**' # Build on push to any branch + paths: + - 'src/shadowbox/**' + - '.github/workflows/build-shadowbox.yml' + pull_request: + paths: + - 'src/shadowbox/**' + workflow_dispatch: + inputs: + tag_suffix: + description: 'Tag suffix for the Docker image (e.g., "wss-test")' + required: false + default: '' + +env: + REGISTRY: ghcr.io + IMAGE_NAME: outline/shadowbox + +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GitHub Container Registry + if: github.event_name != 'pull_request' + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ env.IMAGE_NAME }} + tags: | + # Branch name + type=ref,event=branch + # Tag name + type=ref,event=tag + # PR number + type=ref,event=pr + # Latest tag for main/master branch + type=raw,value=latest,enable={{is_default_branch}} + # SHA short + type=sha,prefix={{branch}}- + # Custom suffix if provided + type=raw,value={{branch}}-${{ github.event.inputs.tag_suffix }},enable=${{ github.event.inputs.tag_suffix != '' }} + # WSS-specific tags + type=raw,value=wss-latest,enable=${{ startsWith(github.ref, 'refs/heads/wss-') }} + type=raw,value=wss-{{branch}},enable=${{ startsWith(github.ref, 'refs/heads/wss-') }} + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '18' + cache: 'npm' + + - name: Setup Go + uses: actions/setup-go@v4 + with: + go-version: '1.21' + + - name: Install dependencies + run: npm ci + + - name: Update Go dependencies + run: | + go mod download + go mod tidy + + - name: Install Task + run: sh -c "$(curl --location https://taskfile.dev/install.sh)" -- -d -b /usr/local/bin + + - name: Build Shadowbox for amd64 + env: + OUTPUT_BASE: ${{ github.workspace }}/build + DOCKER_CONTENT_TRUST: "0" # Disable content trust for CI builds + run: | + # Build from root directory to have access to all taskfiles + task shadowbox:docker:build TARGET_ARCH=x86_64 IMAGE_NAME=${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ env.IMAGE_NAME }}:amd64-${{ github.sha }} IMAGE_VERSION=${{ github.sha }} + + - name: Build Shadowbox for arm64 + env: + OUTPUT_BASE: ${{ github.workspace }}/build + DOCKER_CONTENT_TRUST: "0" # Disable content trust for CI builds + run: | + # Build from root directory to have access to all taskfiles + task shadowbox:docker:build TARGET_ARCH=arm64 IMAGE_NAME=${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ env.IMAGE_NAME }}:arm64-${{ github.sha }} IMAGE_VERSION=${{ github.sha }} + + - name: Push images + if: github.event_name != 'pull_request' + run: | + docker push ${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ env.IMAGE_NAME }}:amd64-${{ github.sha }} + docker push ${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ env.IMAGE_NAME }}:arm64-${{ github.sha }} + + - name: Create and push manifest + if: github.event_name != 'pull_request' + env: + DOCKER_CLI_EXPERIMENTAL: enabled + run: | + # Parse tags and create/push manifest for each + echo "${{ steps.meta.outputs.tags }}" | while read -r tag; do + echo "Creating manifest for ${tag}" + docker manifest create ${tag} \ + --amend ${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ env.IMAGE_NAME }}:amd64-${{ github.sha }} \ + --amend ${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ env.IMAGE_NAME }}:arm64-${{ github.sha }} + docker manifest push ${tag} + done \ No newline at end of file diff --git a/.github/workflows/build_and_test_debug.yml b/.github/workflows/build_and_test_debug.yml index fff4e8c15..f76bd89fc 100644 --- a/.github/workflows/build_and_test_debug.yml +++ b/.github/workflows/build_and_test_debug.yml @@ -15,7 +15,7 @@ name: Build and Test concurrency: - group: ${{ github.head_ref || github.ref }} + group: build-and-test-${{ github.head_ref || github.ref }} cancel-in-progress: true on: diff --git a/.github/workflows/license.yml b/.github/workflows/license.yml index c99a0f7b5..58d4d9913 100644 --- a/.github/workflows/license.yml +++ b/.github/workflows/license.yml @@ -15,7 +15,7 @@ name: License checks concurrency: - group: ${{ github.head_ref || github.ref }} + group: license-${{ github.head_ref || github.ref }} cancel-in-progress: true on: diff --git a/.gitignore b/.gitignore index b0b6e5184..d6ed4f73d 100644 --- a/.gitignore +++ b/.gitignore @@ -8,4 +8,5 @@ /task macos-signing-certificate.p12 node_modules/ -third_party/shellcheck/download/ \ No newline at end of file +third_party/shellcheck/download/ +CLAUDE.md \ No newline at end of file diff --git a/go.mod b/go.mod index d519122ea..52cd91d41 100644 --- a/go.mod +++ b/go.mod @@ -1,47 +1,63 @@ module localhost -go 1.21 +go 1.23 + +toolchain go1.24.5 require ( - github.com/Jigsaw-Code/outline-ss-server v1.7.3 + github.com/Jigsaw-Code/outline-ss-server v1.9.2 github.com/go-task/task/v3 v3.36.0 github.com/google/addlicense v1.1.1 ) +tool ( + github.com/caddyserver/xcaddy/cmd/xcaddy +) + require ( - github.com/Jigsaw-Code/outline-sdk v0.0.14 // indirect - github.com/Masterminds/semver/v3 v3.2.1 // indirect + github.com/Jigsaw-Code/outline-sdk v0.0.18-0.20241106233708-faffebb12629 // indirect + github.com/Jigsaw-Code/outline-sdk/x v0.0.2-0.20250304133713-52f1a365e5ed // indirect + github.com/Masterminds/semver/v3 v3.4.0 // indirect + github.com/akavel/rsrc v0.10.2 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/bmatcuk/doublestar/v4 v4.0.2 // indirect - github.com/cespare/xxhash/v2 v2.2.0 // indirect + github.com/caddyserver/xcaddy v0.4.5 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/fatih/color v1.16.0 // indirect github.com/go-task/slim-sprig/v3 v3.0.0 // indirect - github.com/golang/protobuf v1.5.3 // indirect + github.com/go-viper/mapstructure/v2 v2.2.1 // indirect + github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect + github.com/gorilla/handlers v1.4.1 // indirect + github.com/gorilla/websocket v1.5.3 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/joho/godotenv v1.5.1 // indirect - github.com/klauspost/cpuid/v2 v2.0.9 // indirect + github.com/josephspurrier/goversioninfo v1.5.0 // indirect + github.com/klauspost/compress v1.17.11 // indirect + github.com/klauspost/cpuid/v2 v2.2.9 // indirect github.com/lmittmann/tint v1.0.5 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-zglob v0.0.4 // indirect - github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect - github.com/oschwald/geoip2-golang v1.8.0 // indirect - github.com/oschwald/maxminddb-golang v1.10.0 // indirect - github.com/prometheus/client_golang v1.15.0 // indirect - github.com/prometheus/client_model v0.3.0 // indirect - github.com/prometheus/common v0.42.0 // indirect - github.com/prometheus/procfs v0.9.0 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/oschwald/geoip2-golang v1.11.0 // indirect + github.com/oschwald/maxminddb-golang v1.13.1 // indirect + github.com/prometheus/client_golang v1.20.5 // indirect + github.com/prometheus/client_model v0.6.1 // indirect + github.com/prometheus/common v0.62.0 // indirect + github.com/prometheus/procfs v0.15.1 // indirect github.com/radovskyb/watcher v1.0.7 // indirect github.com/sajari/fuzzy v1.0.0 // indirect github.com/shadowsocks/go-shadowsocks2 v0.1.5 // indirect - github.com/spf13/pflag v1.0.5 // indirect + github.com/spf13/cobra v1.9.1 // indirect + github.com/spf13/pflag v1.0.6 // indirect github.com/zeebo/xxh3 v1.0.2 // indirect - golang.org/x/crypto v0.31.0 // indirect - golang.org/x/sync v0.7.0 // indirect - golang.org/x/sys v0.28.0 // indirect - golang.org/x/term v0.27.0 // indirect - google.golang.org/protobuf v1.30.0 // indirect + golang.org/x/crypto v0.32.0 // indirect + golang.org/x/sync v0.11.0 // indirect + golang.org/x/sys v0.29.0 // indirect + golang.org/x/term v0.28.0 // indirect + google.golang.org/protobuf v1.36.4 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect mvdan.cc/sh/v3 v3.8.0 // indirect ) diff --git a/go.sum b/go.sum index 96578b726..e676c5cab 100644 --- a/go.sum +++ b/go.sum @@ -1,15 +1,22 @@ -github.com/Jigsaw-Code/outline-sdk v0.0.14 h1:uJLvIne7YJNolbX7KDacd8gLidrUzRuweBO2APmQEmI= -github.com/Jigsaw-Code/outline-sdk v0.0.14/go.mod h1:9cEaF6sWWMzY8orcUI9pV5D0oFp2FZArTSyJiYtMQQs= -github.com/Jigsaw-Code/outline-ss-server v1.7.3 h1:UF8AaOV2agRb6edF0U0CtTcwpyIxm6NVDa5QLkQh28E= -github.com/Jigsaw-Code/outline-ss-server v1.7.3/go.mod h1:cKPicPWlLWZKJfkQ3CBpQm8a3gXrA2+dpQvsECqBVz8= -github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0= -github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ= +github.com/Jigsaw-Code/outline-sdk v0.0.18-0.20241106233708-faffebb12629 h1:sHi1X4vwtNNBUDCbxynGXe7cM/inwTbavowHziaxlbk= +github.com/Jigsaw-Code/outline-sdk v0.0.18-0.20241106233708-faffebb12629/go.mod h1:CFDKyGZA4zatKE4vMLe8TyQpZCyINOeRFbMAmYHxodw= +github.com/Jigsaw-Code/outline-sdk/x v0.0.2-0.20250304133713-52f1a365e5ed h1:NfybsWzXQLPNueDsoPJMmvw/i7hWXqk9xaoA9X1cGgM= +github.com/Jigsaw-Code/outline-sdk/x v0.0.2-0.20250304133713-52f1a365e5ed/go.mod h1:aFUEz6Z/eD0NS3c3fEIX+JO2D9aIrXCmWTb1zJFlItw= +github.com/Jigsaw-Code/outline-ss-server v1.9.2 h1:8AlzPLugCCa9H4ZIV79rWOdgVshRzKZalq8ZD+APjqk= +github.com/Jigsaw-Code/outline-ss-server v1.9.2/go.mod h1:v0jS3ExOGwGTbWTpOw16/sid91k7PKxazdK9eLCpUlQ= +github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= +github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/akavel/rsrc v0.10.2 h1:Zxm8V5eI1hW4gGaYsJQUhxpjkENuG91ki8B4zCrvEsw= +github.com/akavel/rsrc v0.10.2/go.mod h1:uLoCtb9J+EyAqh+26kdrTgmzRBFPGOolLWKpdxkKq+c= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bmatcuk/doublestar/v4 v4.0.2 h1:X0krlUVAVmtr2cRoTqR8aDMrDqnB36ht8wpWTiQ3jsA= github.com/bmatcuk/doublestar/v4 v4.0.2/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= -github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= -github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/caddyserver/xcaddy v0.4.5 h1:7E4b+3Gm2do/WpuDXh5MWIj+qgCCvQqR487Sm8C6hwc= +github.com/caddyserver/xcaddy v0.4.5/go.mod h1:QrRLASVAsoDY2MvXRm0pAKZRo4MsJakfvKCYQILGPzo= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.21 h1:1/QdRyBaHHJP61QkWMXlOIBfsgdDeeKfK8SYVUWJKf0= github.com/creack/pty v1.1.21/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= @@ -22,24 +29,34 @@ github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1v github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= github.com/go-task/task/v3 v3.36.0 h1:XVJ5hQ5hdzTAulHpAGzbUMUuYr9MUOEQFOFazI3hUsY= github.com/go-task/task/v3 v3.36.0/go.mod h1:XBCIAzuyG/mgZVHMUm3cCznz4+IpsBQRlW1gw7OA5sA= -github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk= -github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= -github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= -github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss= +github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/google/addlicense v1.1.1 h1:jpVf9qPbU8rz5MxKo7d+RMcNHkqxi4YJi/laauX4aAE= github.com/google/addlicense v1.1.1/go.mod h1:Sm/DHu7Jk+T5miFHHehdIjbi4M5+dJDRS3Cq0rncIxA= -github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= +github.com/gorilla/handlers v1.4.1 h1:BHvcRGJe/TrL+OqFxoKQGddTgeibiOjaBssV5a/N9sw= +github.com/gorilla/handlers v1.4.1/go.mod h1:Qkdc/uu4tH4g6mTK6auzZ766c4CA0Ng8+o/OAirnOIQ= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= -github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4= -github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/josephspurrier/goversioninfo v1.5.0 h1:9TJtORoyf4YMoWSOo/cXFN9A/lB3PniJ91OxIH6e7Zg= +github.com/josephspurrier/goversioninfo v1.5.0/go.mod h1:6MoTvFZ6GKJkzcdLnU5T/RGYUbHQbKpYeNP0AgQLd2o= +github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc= +github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0= +github.com/klauspost/cpuid/v2 v2.2.9 h1:66ze0taIn2H33fBvCkXuv9BmCwDfafmiIVpKV9kKGuY= +github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/lmittmann/tint v1.0.5 h1:NQclAutOfYsqs2F1Lenue6OoWCajs5wJcP3DfWVpePw= github.com/lmittmann/tint v1.0.5/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= @@ -49,70 +66,69 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-zglob v0.0.4 h1:LQi2iOm0/fGgu80AioIJ/1j9w9Oh+9DZ39J4VAGzHQM= github.com/mattn/go-zglob v0.0.4/go.mod h1:MxxjyoXXnMxfIpxTK2GAkw1w8glPsQILx3N5wrKakiY= -github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo= -github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4= github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/op/go-logging v0.0.0-20160315200505-970db520ece7 h1:lDH9UUVJtmYCjyT0CI4q8xvlXPxeZ0gYCVvWbmPlp88= github.com/op/go-logging v0.0.0-20160315200505-970db520ece7/go.mod h1:HzydrMdWErDVzsI23lYNej1Htcns9BCg93Dk0bBINWk= -github.com/oschwald/geoip2-golang v1.8.0 h1:KfjYB8ojCEn/QLqsDU0AzrJ3R5Qa9vFlx3z6SLNcKTs= -github.com/oschwald/geoip2-golang v1.8.0/go.mod h1:R7bRvYjOeaoenAp9sKRS8GX5bJWcZ0laWO5+DauEktw= -github.com/oschwald/maxminddb-golang v1.10.0 h1:Xp1u0ZhqkSuopaKmk1WwHtjF0H9Hd9181uj2MQ5Vndg= -github.com/oschwald/maxminddb-golang v1.10.0/go.mod h1:Y2ELenReaLAZ0b400URyGwvYxHV1dLIxBuyOsyYjHK0= +github.com/oschwald/geoip2-golang v1.11.0 h1:hNENhCn1Uyzhf9PTmquXENiWS6AlxAEnBII6r8krA3w= +github.com/oschwald/geoip2-golang v1.11.0/go.mod h1:P9zG+54KPEFOliZ29i7SeYZ/GM6tfEL+rgSn03hYuUo= +github.com/oschwald/maxminddb-golang v1.13.1 h1:G3wwjdN9JmIK2o/ermkHM+98oX5fS+k5MbwsmL4MRQE= +github.com/oschwald/maxminddb-golang v1.13.1/go.mod h1:K4pgV9N/GcK694KSTmVSDTODk4IsCNThNdTmnaBZ/F8= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/prometheus/client_golang v1.15.0 h1:5fCgGYogn0hFdhyhLbw7hEsWxufKtY9klyvdNfFlFhM= -github.com/prometheus/client_golang v1.15.0/go.mod h1:e9yaBhRPU2pPNsZwE+JdQl0KEt1N9XgF6zxWmaC0xOk= -github.com/prometheus/client_model v0.3.0 h1:UBgGFHqYdG/TPFD1B1ogZywDqEkwp3fBMvqdiQ7Xew4= -github.com/prometheus/client_model v0.3.0/go.mod h1:LDGWKZIo7rky3hgvBe+caln+Dr3dPggB5dvjtD7w9+w= -github.com/prometheus/common v0.42.0 h1:EKsfXEYo4JpWMHH5cg+KOUWeuJSov1Id8zGR8eeI1YM= -github.com/prometheus/common v0.42.0/go.mod h1:xBwqVerjNdUDjgODMpudtOMwlOwf2SaTr1yjz4b7Zbc= -github.com/prometheus/procfs v0.9.0 h1:wzCHvIvM5SxWqYvwgVL7yJY8Lz3PKn49KQtpgMYJfhI= -github.com/prometheus/procfs v0.9.0/go.mod h1:+pB4zwohETzFnmlpe6yd2lSc+0/46IYZRB/chUwxUZY= +github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y= +github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE= +github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= +github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= +github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io= +github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I= +github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= +github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= github.com/radovskyb/watcher v1.0.7 h1:AYePLih6dpmS32vlHfhCeli8127LzkIgwJGcwwe8tUE= github.com/radovskyb/watcher v1.0.7/go.mod h1:78okwvY5wPdzcb1UYnip1pvrZNIVEIh/Cm+ZuvsUYIg= github.com/riobard/go-bloom v0.0.0-20200614022211-cdc8013cb5b3 h1:f/FNXud6gA3MNr8meMVVGxhp+QBTqY91tM8HjEuMjGg= github.com/riobard/go-bloom v0.0.0-20200614022211-cdc8013cb5b3/go.mod h1:HgjTstvQsPGkxUsCd2KWxErBblirPizecHcpD3ffK+s= -github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= -github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= +github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sajari/fuzzy v1.0.0 h1:+FmwVvJErsd0d0hAPlj4CxqxUtQY/fOoY0DwX4ykpRY= github.com/sajari/fuzzy v1.0.0/go.mod h1:OjYR6KxoWOe9+dOlXeiCJd4dIbED4Oo8wpS89o0pwOo= github.com/shadowsocks/go-shadowsocks2 v0.1.5 h1:PDSQv9y2S85Fl7VBeOMF9StzeXZyK1HakRm86CUbr28= github.com/shadowsocks/go-shadowsocks2 v0.1.5/go.mod h1:AGGpIoek4HRno4xzyFiAtLHkOpcoznZEkAccaI/rplM= -github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= -github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= +github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= +github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= +github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= -github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= -github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ= github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210220033148-5ea612d1eb83/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= -golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= -golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= +golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc= +golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= -golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w= +golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= -golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= +golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= -golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q= -golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= +golang.org/x/term v0.28.0 h1:/Ts8HFuMR2E6IP/jlo7QVLZHggjKQbhu/7H0LJFr3Gg= +golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= -google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= -google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng= -google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +google.golang.org/protobuf v1.36.4 h1:6A3ZDJHn/eNqc1i+IdefRzy/9PokBTPvcqMySR7NNIM= +google.golang.org/protobuf v1.36.4/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/src/shadowbox/README.md b/src/shadowbox/README.md index 8462adf74..ea7068f86 100644 --- a/src/shadowbox/README.md +++ b/src/shadowbox/README.md @@ -115,6 +115,89 @@ The Outline Server provides a REST API for access key management. If you know th Consult the [OpenAPI spec](./server/api.yml) and [documentation](https://redocly.github.io/redoc/?url=https://raw.githubusercontent.com/OutlineFoundation/outline-server/master/src/shadowbox/server/api.yml) for more options. +## WebSocket Support (SS over WSS) + +The Outline Server supports Shadowsocks over WebSocket (SS over WSS) for improved censorship resistance. This tunnels Shadowsocks traffic over WebSocket connections, making it look like regular HTTPS web traffic. + +### Enabling WebSocket Support + +1. **Configure Listeners:** + + Set defaults for new keys (add `"applyToExisting": true` to update all existing keys): + + ```sh + curl --insecure -X PUT -H "Content-Type: application/json" \ + -d '{ + "tcp": {"port": 443}, + "udp": {"port": 443}, + "websocketStream": {"path": "/tcp", "webServerPort": 8080}, + "websocketPacket": {"path": "/udp", "webServerPort": 8080} + }' \ + $API_URL/server/listeners + ``` + +2. **Enable the Caddy Web Server (for automatic HTTPS):** + + ```sh + curl --insecure -X PUT -H "Content-Type: application/json" \ + -d '{ + "enabled": true, + "autoHttps": true, + "email": "admin@example.com", + "domain": "your-domain.com" + }' \ + $API_URL/server/web-server + ``` + +3. **Update a Specific Key's Listeners:** + + ```sh + curl --insecure -X PUT -H "Content-Type: application/json" \ + -d '{"listeners": ["tcp", "udp", "websocket-stream", "websocket-packet"]}' \ + $API_URL/access-keys/0/listeners + ``` + +4. **Get Dynamic Config (YAML):** + + Use the dedicated endpoint to retrieve YAML transport configuration (Outline Client v1.15.0+): + + ```sh + curl --insecure $API_URL/access-keys/0/dynamic-config + ``` + + Example response (`Content-Type: text/yaml`): + + ```yaml + transport: + $type: tcpudp + tcp: + $type: shadowsocks + endpoint: + $type: websocket + url: wss://example.com/tcp + cipher: chacha20-ietf-poly1305 + secret: XxXxXx + udp: + $type: shadowsocks + endpoint: + $type: websocket + url: wss://example.com/udp + cipher: chacha20-ietf-poly1305 + secret: XxXxXx + ``` + +> [!NOTE] > `GET /access-keys/{id}` always returns JSON. Use `/access-keys/{id}/dynamic-config` for YAML transport configuration. + +### Listener Types + +- `tcp` - Traditional TCP Shadowsocks +- `udp` - Traditional UDP Shadowsocks +- `websocket-stream` - TCP over WebSocket (for TCP traffic tunneling) +- `websocket-packet` - UDP over WebSocket (for UDP traffic tunneling) + +> [!NOTE] +> WebSocket support requires a reverse proxy (like Caddy, Nginx, or Cloudflare Tunnel) in front of the internal WebSocket server port (default: 8080) to handle TLS termination and external traffic. + ## Testing ### Manual diff --git a/src/shadowbox/Taskfile.yml b/src/shadowbox/Taskfile.yml index fb72a9b87..52e2b2d25 100644 --- a/src/shadowbox/Taskfile.yml +++ b/src/shadowbox/Taskfile.yml @@ -18,6 +18,18 @@ requires: vars: [OUTPUT_BASE] tasks: + build_outline_caddy: + desc: Build the OutlineCaddy binary using xcaddy + requires: {vars: [TARGET_DIR, TARGET_OS, GOARCH]} + cmds: + - mkdir -p '{{.TARGET_DIR}}' + - | + XCADDY_GOOS={{.TARGET_OS}} XCADDY_GOARCH={{.GOARCH}} go tool xcaddy build \ + --output '{{joinPath .TARGET_DIR "outline-caddy"}}' \ + --with github.com/Jigsaw-Code/outline-ss-server/outlinecaddy@v0.0.1 \ + --with github.com/iamd3vil/caddy_yaml_adapter \ + --with github.com/mholt/caddy-l4 + build: desc: Build the Outline Server Node.js app vars: @@ -40,6 +52,11 @@ tasks: vars: {TARGET_DIR: '{{.BIN_DIR}}'} # Set CGO_ENABLED=0 to force static linkage. See https://mt165.co.uk/blog/static-link-go/. - 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 + - task: build_outline_caddy + vars: + TARGET_DIR: '{{.BIN_DIR}}' + TARGET_OS: '{{.TARGET_OS}}' + GOARCH: '{{.GOARCH}}' start: desc: Run the Outline server locally @@ -204,4 +221,4 @@ tasks: - > openssl req -x509 -nodes -days 36500 -newkey rsa:4096 -subj "/CN=localhost" - -keyout "{{.PRIVATE_KEY_FILE}}" -out "{{.CERTIFICATE_FILE}}" \ No newline at end of file + -keyout "{{.PRIVATE_KEY_FILE}}" -out "{{.CERTIFICATE_FILE}}" diff --git a/src/shadowbox/model/access_key.ts b/src/shadowbox/model/access_key.ts index 5ed57b188..baa1fbc83 100644 --- a/src/shadowbox/model/access_key.ts +++ b/src/shadowbox/model/access_key.ts @@ -14,6 +14,9 @@ export type AccessKeyId = string; +// Listener types that an access key can use +export type ListenerType = 'tcp' | 'udp' | 'websocket-stream' | 'websocket-packet'; + // Parameters needed to access a Shadowsocks proxy. export interface ProxyParams { // Hostname of the proxy @@ -43,6 +46,8 @@ export interface AccessKey { readonly reachedDataLimit: boolean; // The key's current data limit. If it exists, it overrides the server default data limit. readonly dataLimit?: DataLimit; + // Listeners enabled for this access key + readonly listeners?: ListenerType[]; } export interface AccessKeyCreateParams { @@ -58,6 +63,8 @@ export interface AccessKeyCreateParams { readonly dataLimit?: DataLimit; // The port number to use for the access key. readonly portNumber?: number; + // Listeners to enable for this access key. + readonly listeners?: ListenerType[]; } export interface AccessKeyRepository { @@ -83,4 +90,8 @@ export interface AccessKeyRepository { setAccessKeyDataLimit(id: AccessKeyId, limit: DataLimit): void; // Removes the custom data limit from access key `id`. removeAccessKeyDataLimit(id: AccessKeyId): void; + // Updates the listeners for access key `id`. + setAccessKeyListeners(id: AccessKeyId, listeners: ListenerType[]): void; + // Updates the listeners for all access keys. + setListenersForAllKeys(listeners: ListenerType[]): void; } diff --git a/src/shadowbox/model/shadowsocks_server.ts b/src/shadowbox/model/shadowsocks_server.ts index a3416ef95..2c6a6c39c 100644 --- a/src/shadowbox/model/shadowsocks_server.ts +++ b/src/shadowbox/model/shadowsocks_server.ts @@ -20,7 +20,20 @@ export interface ShadowsocksAccessKey { secret: string; } +export interface ListenerSettings { + websocketStream?: { + path?: string; + webServerPort?: number; + }; + websocketPacket?: { + path?: string; + webServerPort?: number; + }; +} + export interface ShadowsocksServer { // Updates the server to accept only the given access keys. update(keys: ShadowsocksAccessKey[]): Promise; + // Optionally updates listener-specific configuration such as WebSocket paths or ports. + configureListeners?(listeners: ListenerSettings | undefined): void; } diff --git a/src/shadowbox/server/api.yml b/src/shadowbox/server/api.yml index 2532d51ce..2aed0721a 100644 --- a/src/shadowbox/server/api.yml +++ b/src/shadowbox/server/api.yml @@ -99,6 +99,68 @@ paths: '409': description: The requested port was already in use by another service. + /server/listeners: + put: + description: | + Sets the listeners configuration for access keys. + - By default, only affects newly created keys + - Set `applyToExisting: true` to also update all existing keys + + Use PUT /access-keys/{id}/listeners for per-key overrides. + tags: + - Server + requestBody: + required: true + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/ListenersConfig" + - type: object + properties: + applyToExisting: + type: boolean + default: false + description: If true, also update all existing access keys + examples: + 'Defaults only': + value: '{"tcp": {"port": 443}, "udp": {"port": 443}}' + 'With WebSocket': + value: '{"tcp": {"port": 443}, "websocketStream": {"path": "/tcp", "webServerPort": 8080}, "websocketPacket": {"path": "/udp", "webServerPort": 8080}}' + 'Update all existing keys': + value: '{"tcp": {"port": 443}, "websocketStream": {"path": "/tcp"}, "applyToExisting": true}' + responses: + '204': + description: The listeners configuration was successfully updated. + '400': + description: Invalid listeners configuration. + '409': + description: One or more requested ports were already in use by another service. + + /server/web-server: + put: + description: Configures the Caddy web server for automatic HTTPS and WebSocket reverse proxy. + tags: + - Server + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/WebServerConfig" + examples: + 'Enable with auto HTTPS': + value: '{"enabled": true, "autoHttps": true, "email": "admin@example.com", "domain": "example.com"}' + 'Basic configuration': + value: '{"enabled": true, "domain": "example.com"}' + responses: + '204': + description: The web server configuration was successfully updated. + '400': + description: Invalid web server configuration. + '500': + description: Failed to configure the embedded Caddy web server. + /server/access-key-data-limit: put: description: Sets a data transfer limit for all access keys @@ -278,11 +340,18 @@ paths: type: integer limit: $ref: "#/components/schemas/DataLimit" + listeners: + type: array + items: + type: string + enum: ['tcp', 'udp', 'websocket-stream', 'websocket-packet'] examples: 'No params specified': value: '{"method":"aes-192-gcm"}' 'Provide params': value: '{"method":"aes-192-gcm","name":"First","password":"8iu8V8EeoFVpwQvQeS9wiD","port": 12345,"limit":{"bytes":10000}}' + 'With listeners': + value: '{"name":"WSS User","listeners":["tcp","udp","websocket-stream","websocket-packet"]}' responses: '201': description: The newly created access key @@ -364,7 +433,7 @@ paths: value: >- {"id":"my-identifier","name":"First","password":"XxXxXx","port":9795,"method":"chacha20-ietf-poly1305","accessUrl":"ss://SADFJSKADFJAKSD@0.0.0.0:9795/?outline=1"} get: - description: Get an access key + description: Get an access key (always returns JSON) tags: - Access Key parameters: @@ -382,8 +451,10 @@ paths: schema: $ref: "#/components/schemas/AccessKey" examples: - '0': + 'Traditional key': value: '{"id":"0","name":"Admin","password":"XxXxXx","port":18162,"method":"chacha20-ietf-poly1305","accessUrl":"ss://SADFJSKADFJAKSD@0.0.0.0:18162/?outline=1"}' + 'WebSocket key': + value: '{"id":"1","name":"WSS User","listeners":["websocket-stream","websocket-packet"],"dynamicConfig":{"transport":{"$type":"tcpudp"}}}' '404': description: Access key inexistent content: @@ -458,6 +529,91 @@ paths: description: Access key renamed successfully '404': description: Access key inexistent + /access-keys/{id}/listeners: + put: + description: | + Updates the listeners for a specific access key. + This overrides the global /server/listeners setting for this key. + tags: + - Access Key + parameters: + - name: id + in: path + required: true + description: The id of the access key + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - listeners + properties: + listeners: + type: array + items: + type: string + enum: ['tcp', 'udp', 'websocket-stream', 'websocket-packet'] + examples: + 'All transports': + value: '{"listeners": ["tcp", "udp", "websocket-stream", "websocket-packet"]}' + 'WebSocket only': + value: '{"listeners": ["websocket-stream", "websocket-packet"]}' + 'Traditional only': + value: '{"listeners": ["tcp", "udp"]}' + responses: + '204': + description: Listeners updated successfully + '400': + description: Invalid listeners configuration + '404': + description: Access key not found + /access-keys/{id}/dynamic-config: + get: + description: | + Returns the dynamic transport configuration for WebSocket-enabled keys. + Returns YAML content compatible with Outline Client v1.15.0+. + Returns 404 if the key has no WebSocket listeners. + tags: + - Access Key + parameters: + - name: id + in: path + required: true + description: The id of the access key + schema: + type: string + responses: + '200': + description: Dynamic transport configuration in YAML format + content: + text/yaml: + schema: + type: string + examples: + 'WebSocket config': + value: | + transport: + $type: tcpudp + tcp: + $type: shadowsocks + endpoint: + $type: websocket + url: wss://example.com/tcp + cipher: chacha20-ietf-poly1305 + secret: XxXxXx + udp: + $type: shadowsocks + endpoint: + $type: websocket + url: wss://example.com/udp + cipher: chacha20-ietf-poly1305 + secret: XxXxXx + '404': + description: Access key not found or has no WebSocket listeners /access-keys/{id}/data-limit: put: description: Sets a data limit for the given access key @@ -617,19 +773,100 @@ components: type: integer minimum: 0 + ListenerConfig: + properties: + port: + type: integer + minimum: 1 + maximum: 65535 + description: Port number for the listener + path: + type: string + description: Path for WebSocket listeners (e.g., "/tcp" or "/udp") + webServerPort: + type: integer + minimum: 1 + maximum: 65535 + description: Port for the internal WebSocket server (must be shared by both WebSocket listeners) + + ListenersConfig: + properties: + tcp: + $ref: "#/components/schemas/ListenerConfig" + description: TCP listener configuration + udp: + $ref: "#/components/schemas/ListenerConfig" + description: UDP listener configuration + websocketStream: + $ref: "#/components/schemas/ListenerConfig" + description: WebSocket stream (TCP) listener configuration + websocketPacket: + $ref: "#/components/schemas/ListenerConfig" + description: WebSocket packet (UDP) listener configuration + + WebServerConfig: + properties: + enabled: + type: boolean + description: Whether Caddy web server integration is enabled + autoHttps: + type: boolean + description: Whether to enable automatic HTTPS via ACME + email: + type: string + description: Email address for ACME registration + domain: + type: string + description: Domain name for automatic HTTPS + apiProxyPath: + type: string + description: Path prefix for API reverse proxy (e.g., "/api"). When set, Caddy will proxy API requests with valid TLS. + AccessKey: required: - id properties: id: type: string + description: Unique identifier for this access key. name: type: string + description: User-assigned name for this access key. password: type: string + description: | + Shadowsocks password. Only present when the key has TCP or UDP listeners. + For WebSocket-only keys, the secret is in dynamicConfig. port: type: integer + description: | + Port number for direct Shadowsocks connections. Only present when the + key has TCP or UDP listeners. method: type: string + description: | + Encryption method (cipher). Only present when the key has TCP or UDP listeners. + For WebSocket-only keys, the cipher is in dynamicConfig. + dataLimit: + $ref: "#/components/schemas/DataLimit" + description: Optional data transfer limit for this key. accessUrl: type: string + description: | + SIP002-formatted ss:// URL for direct Shadowsocks connections. + Only present when the key has TCP or UDP listeners enabled. + For WebSocket-only keys, use dynamicConfig instead. + listeners: + type: array + items: + type: string + enum: ['tcp', 'udp', 'websocket-stream', 'websocket-packet'] + description: List of enabled listener types for this key. + dynamicConfig: + type: object + description: | + Dynamic access configuration for WebSocket transport (Outline Client v1.15.0+). + Only present when the key has websocket-stream or websocket-packet listeners. + Contains the complete transport configuration including cipher and secret. + additionalProperties: true + diff --git a/src/shadowbox/server/main.ts b/src/shadowbox/server/main.ts index 5d1be7768..7172c979c 100644 --- a/src/shadowbox/server/main.ts +++ b/src/shadowbox/server/main.ts @@ -31,6 +31,7 @@ import * as version from './version'; import {PrometheusManagerMetrics} from './manager_metrics'; import {bindService, ShadowsocksManagerService} from './manager_service'; import {OutlineShadowsocksServer} from './outline_shadowsocks_server'; +import {OutlineCaddyServer} from './outline_caddy_server'; import {AccessKeyConfigJson, ServerAccessKeyRepository} from './server_access_key'; import * as server_config from './server_config'; import { @@ -162,6 +163,26 @@ async function main() { if (fs.existsSync(MMDB_LOCATION_ASN)) { shadowsocksServer.configureAsnMetrics(MMDB_LOCATION_ASN); } + const caddyServer = new OutlineCaddyServer( + getBinaryFilename('outline-caddy'), + getPersistentFilename('outline-caddy/config.yaml'), + verbose + ); + + // Configure listener defaults (e.g., WebSocket paths/ports) based on server configuration. + const listenersConfig = serverConfig.data().listeners; + shadowsocksServer.configureListeners( + listenersConfig + ? { + websocketStream: listenersConfig.websocketStream + ? {...listenersConfig.websocketStream} + : undefined, + websocketPacket: listenersConfig.websocketPacket + ? {...listenersConfig.websocketPacket} + : undefined, + } + : undefined + ); const isReplayProtectionEnabled = createRolloutTracker(serverConfig).isRolloutEnabled( 'replay-protection', @@ -212,6 +233,21 @@ async function main() { serverConfig.data().accessKeyDataLimit ); + // Determine if API should be proxied through Caddy + const apiProxyEnabled = !!serverConfig.data().caddyWebServer?.apiProxyPath; + + try { + await caddyServer.applyConfig({ + accessKeys: accessKeyRepository.listAccessKeys(), + listeners: serverConfig.data().listeners, + caddyConfig: serverConfig.data().caddyWebServer, + hostname: serverConfig.data().hostname, + apiPort: apiProxyEnabled ? apiPortNumber : undefined, + }); + } catch (error) { + logging.error(`Failed to apply initial Caddy configuration: ${error}`); + } + const metricsReader = new PrometheusUsageMetrics(prometheusClient); const managerMetrics = new PrometheusManagerMetrics(prometheusClient); const metricsCollector = new RestMetricsCollectorClient(metricsCollectorUrl); @@ -228,11 +264,15 @@ async function main() { accessKeyRepository, shadowsocksServer, managerMetrics, - metricsPublisher + metricsPublisher, + caddyServer ); const certificateFilename = process.env.SB_CERTIFICATE_FILE; const privateKeyFilename = process.env.SB_PRIVATE_KEY_FILE; + + // Create API server with HTTPS (self-signed cert) + // When apiProxyPath is set, Caddy also proxies to this with TLS verification disabled const apiServer = restify.createServer({ certificate: fs.readFileSync(certificateFilename), key: fs.readFileSync(privateKeyFilename), @@ -255,6 +295,7 @@ async function main() { apiServer.use(cors.actual); bindService(apiServer, apiPrefix, managerService); + // Listen on all interfaces apiServer.listen(apiPortNumber, () => { logging.info(`Manager listening at ${apiServer.url}${apiPrefix}`); }); diff --git a/src/shadowbox/server/manager_service.spec.ts b/src/shadowbox/server/manager_service.spec.ts index 22e04c817..34618541c 100644 --- a/src/shadowbox/server/manager_service.spec.ts +++ b/src/shadowbox/server/manager_service.spec.ts @@ -25,6 +25,7 @@ import {AccessKeyConfigJson, ServerAccessKeyRepository} from './server_access_ke import {ServerConfigJson} from './server_config'; import {SharedMetricsPublisher} from './shared_metrics'; import {ShadowsocksServer} from '../model/shadowsocks_server'; +import type {OutlineCaddyConfigPayload, OutlineCaddyController} from './outline_caddy_server'; interface ServerInfo { name: string; @@ -41,6 +42,18 @@ const EXPECTED_ACCESS_KEY_PROPERTIES = [ 'method', 'accessUrl', 'dataLimit', + 'listeners', +].sort(); + +// Keys created directly via repo don't have listeners set +const EXPECTED_ACCESS_KEY_PROPERTIES_WITHOUT_LISTENERS = [ + 'id', + 'name', + 'password', + 'port', + 'method', + 'accessUrl', + 'dataLimit', ].sort(); const SEND_NOTHING = (_httpCode, _data) => {}; @@ -58,7 +71,7 @@ describe('ShadowsocksManagerService', () => { }); describe('getServer', () => { - it('Return default name if name is absent', (done) => { + it('Return default name if name is absent', () => { const repo = getAccessKeyRepository(); const serverConfig = new InMemoryConfig({} as ServerConfigJson); const service = new ShadowsocksManagerServiceBuilder() @@ -74,10 +87,10 @@ describe('ShadowsocksManagerService', () => { responseProcessed = true; }, }, - done + () => {} ); }); - it('Returns persisted properties', (done) => { + it('Returns persisted properties', () => { const repo = getAccessKeyRepository(); const defaultDataLimit = {bytes: 999}; const serverConfig = new InMemoryConfig({ @@ -98,13 +111,13 @@ describe('ShadowsocksManagerService', () => { responseProcessed = true; }, }, - done + () => {} ); }); }); describe('renameServer', () => { - it('Rename changes the server name', (done) => { + it('Rename changes the server name', () => { const repo = getAccessKeyRepository(); const serverConfig = new InMemoryConfig({} as ServerConfigJson); const service = new ShadowsocksManagerServiceBuilder() @@ -120,13 +133,13 @@ describe('ShadowsocksManagerService', () => { responseProcessed = true; }, }, - done + () => {} ); }); }); describe('setHostnameForAccessKeys', () => { - it(`accepts valid hostnames`, (done) => { + it(`accepts valid hostnames`, async () => { const serverConfig = new InMemoryConfig({} as ServerConfigJson); const service = new ShadowsocksManagerServiceBuilder() .serverConfig(serverConfig) @@ -151,13 +164,12 @@ describe('ShadowsocksManagerService', () => { '2606:2800:220:1:248:1893:25c8:1946', ]; for (const hostname of goodHostnames) { - service.setHostnameForAccessKeys({params: {hostname}}, res, () => {}); + await service.setHostnameForAccessKeys({params: {hostname}}, res, () => {}); } responseProcessed = true; - done(); }); - it(`rejects invalid hostnames`, (done) => { + it(`rejects invalid hostnames`, async () => { const serverConfig = new InMemoryConfig({} as ServerConfigJson); const service = new ShadowsocksManagerServiceBuilder() .serverConfig(serverConfig) @@ -179,13 +191,12 @@ describe('ShadowsocksManagerService', () => { 'gggg:ggg:220:1:248:1893:25c8:1946', ]; for (const hostname of badHostnames) { - service.setHostnameForAccessKeys({params: {hostname}}, res, next); + await service.setHostnameForAccessKeys({params: {hostname}}, res, next); } responseProcessed = true; - done(); }); - it("Changes the server's hostname", (done) => { + it("Changes the server's hostname", async () => { const serverConfig = new InMemoryConfig({} as ServerConfigJson); const service = new ShadowsocksManagerServiceBuilder() .serverConfig(serverConfig) @@ -199,9 +210,9 @@ describe('ShadowsocksManagerService', () => { responseProcessed = true; }, }; - service.setHostnameForAccessKeys({params: {hostname}}, res, done); + await service.setHostnameForAccessKeys({params: {hostname}}, res, () => {}); }); - it('Rejects missing hostname', (done) => { + it('Rejects missing hostname', async () => { const serverConfig = new InMemoryConfig({} as ServerConfigJson); const service = new ShadowsocksManagerServiceBuilder() .serverConfig(serverConfig) @@ -211,12 +222,11 @@ describe('ShadowsocksManagerService', () => { const next = (error) => { expect(error.statusCode).toEqual(400); responseProcessed = true; - done(); }; const missingHostname = {params: {}} as {params: {hostname: string}}; - service.setHostnameForAccessKeys(missingHostname, res, next); + await service.setHostnameForAccessKeys(missingHostname, res, next); }); - it('Rejects non-string hostname', (done) => { + it('Rejects non-string hostname', async () => { const serverConfig = new InMemoryConfig({} as ServerConfigJson); const service = new ShadowsocksManagerServiceBuilder() .serverConfig(serverConfig) @@ -226,16 +236,15 @@ describe('ShadowsocksManagerService', () => { const next = (error) => { expect(error.statusCode).toEqual(400); responseProcessed = true; - done(); }; // eslint-disable-next-line @typescript-eslint/no-explicit-any const badHostname = {params: {hostname: 123}} as any as {params: {hostname: string}}; - service.setHostnameForAccessKeys(badHostname, res, next); + await service.setHostnameForAccessKeys(badHostname, res, next); }); }); describe('getAccessKey', () => { - it('Returns an access key', async (done) => { + it('Returns an access key', async () => { const repo = getAccessKeyRepository(); const service = new ShadowsocksManagerServiceBuilder().accessKeys(repo).build(); const key1 = await createNewAccessKeyWithName(repo, 'keyName1'); @@ -248,23 +257,22 @@ describe('ShadowsocksManagerService', () => { responseProcessed = true; }, }, - done + () => {} ); }); - it('Returns 404 if the access key does not exist', (done) => { + it('Returns 404 if the access key does not exist', () => { const repo = getAccessKeyRepository(); const service = new ShadowsocksManagerServiceBuilder().accessKeys(repo).build(); service.getAccessKey({params: {id: '1'}}, {send: () => {}}, (error) => { expect(error.statusCode).toEqual(404); responseProcessed = true; - done(); }); }); }); describe('listAccessKeys', () => { - it('lists access keys in order', async (done) => { + it('lists access keys in order', async () => { const repo = getAccessKeyRepository(); const service = new ShadowsocksManagerServiceBuilder().accessKeys(repo).build(); // Create 2 access keys with names. @@ -282,9 +290,9 @@ describe('ShadowsocksManagerService', () => { responseProcessed = true; // required for afterEach to pass. }, }; - service.listAccessKeys({params: {}}, res, done); + service.listAccessKeys({params: {}}, res, () => {}); }); - it('lists access keys with expected properties', async (done) => { + it('lists access keys with expected properties', async () => { const repo = getAccessKeyRepository(); const service = new ShadowsocksManagerServiceBuilder().accessKeys(repo).build(); const accessKey = await repo.createNewAccessKey(); @@ -297,13 +305,17 @@ describe('ShadowsocksManagerService', () => { expect(data.accessKeys.length).toEqual(2); const serviceAccessKey1 = data.accessKeys[0]; const serviceAccessKey2 = data.accessKeys[1]; - expect(Object.keys(serviceAccessKey1).sort()).toEqual(EXPECTED_ACCESS_KEY_PROPERTIES); - expect(Object.keys(serviceAccessKey2).sort()).toEqual(EXPECTED_ACCESS_KEY_PROPERTIES); + expect(Object.keys(serviceAccessKey1).sort()).toEqual( + EXPECTED_ACCESS_KEY_PROPERTIES_WITHOUT_LISTENERS + ); + expect(Object.keys(serviceAccessKey2).sort()).toEqual( + EXPECTED_ACCESS_KEY_PROPERTIES_WITHOUT_LISTENERS + ); expect(serviceAccessKey1.name).toEqual(accessKeyName); responseProcessed = true; // required for afterEach to pass. }, }; - service.listAccessKeys({params: {}}, res, done); + service.listAccessKeys({params: {}}, res, () => {}); }); }); @@ -318,7 +330,7 @@ describe('ShadowsocksManagerService', () => { describe('handling the access key identifier', () => { describe("with 'createNewAccessKey'", () => { - it('generates a unique ID', (done) => { + it('generates a unique ID', async () => { const res = { send: (httpCode, data) => { expect(httpCode).toEqual(201); @@ -326,45 +338,41 @@ describe('ShadowsocksManagerService', () => { responseProcessed = true; // required for afterEach to pass. }, }; - service.createNewAccessKey({params: {}}, res, done); + await service.createNewAccessKey({params: {}}, res, () => {}); }); - it('rejects requests with ID parameter set', (done) => { + it('rejects requests with ID parameter set', () => { const res = {send: (_httpCode, _data) => {}}; service.createNewAccessKey({params: {id: 'foobar'}}, res, (error) => { expect(error.statusCode).toEqual(400); responseProcessed = true; // required for afterEach to pass. - done(); }); }); }); describe("with 'createAccessKey'", () => { - it('rejects requests without ID parameter set', (done) => { + it('rejects requests without ID parameter set', () => { const res = {send: (_httpCode, _data) => {}}; service.createAccessKey({params: {}}, res, (error) => { expect(error.statusCode).toEqual(400); responseProcessed = true; // required for afterEach to pass. - done(); }); }); - it('rejects non-string ID', (done) => { + it('rejects non-string ID', () => { const res = {send: (_httpCode, _data) => {}}; service.createAccessKey({params: {id: Number('9876')}}, res, (error) => { expect(error.statusCode).toEqual(400); responseProcessed = true; // required for afterEach to pass. - done(); }); }); - it('rejects if key exists', async (done) => { + it('rejects if key exists', async () => { const accessKey = await repo.createNewAccessKey(); const res = {send: (_httpCode, _data) => {}}; - service.createAccessKey({params: {id: accessKey.id}}, res, (error) => { + await service.createAccessKey({params: {id: accessKey.id}}, res, (error) => { expect(error.statusCode).toEqual(409); responseProcessed = true; // required for afterEach to pass. - done(); }); }); - it('creates key with provided ID', (done) => { + it('creates key with provided ID', async () => { const res = { send: (httpCode, data) => { expect(httpCode).toEqual(201); @@ -372,7 +380,7 @@ describe('ShadowsocksManagerService', () => { responseProcessed = true; // required for afterEach to pass. }, }; - service.createAccessKey({params: {id: 'myKeyId'}}, res, done); + await service.createAccessKey({params: {id: 'myKeyId'}}, res, () => {}); }); }); }); @@ -390,7 +398,7 @@ describe('ShadowsocksManagerService', () => { serviceMethod = service[methodName].bind(service); }); - it('verify default method', (done) => { + it('verify default method', async () => { // Verify that response returns a key with the expected properties. const res = { send: (httpCode, data) => { @@ -400,9 +408,9 @@ describe('ShadowsocksManagerService', () => { responseProcessed = true; // required for afterEach to pass. }, }; - serviceMethod({params: {id: accessKeyId}}, res, done); + await serviceMethod({params: {id: accessKeyId}}, res, () => {}); }); - it('non-default method gets set', (done) => { + it('non-default method gets set', async () => { // Verify that response returns a key with the expected properties. const res = { send: (httpCode, data) => { @@ -412,9 +420,9 @@ describe('ShadowsocksManagerService', () => { responseProcessed = true; // required for afterEach to pass. }, }; - serviceMethod({params: {id: accessKeyId, method: 'aes-256-gcm'}}, res, done); + await serviceMethod({params: {id: accessKeyId, method: 'aes-256-gcm'}}, res, () => {}); }); - it('use default name is params is not defined', (done) => { + it('use default name is params is not defined', async () => { const res = { send: (httpCode, data) => { expect(httpCode).toEqual(201); @@ -422,17 +430,16 @@ describe('ShadowsocksManagerService', () => { responseProcessed = true; // required for afterEach to pass. }, }; - serviceMethod({params: {id: accessKeyId}}, res, done); + await serviceMethod({params: {id: accessKeyId}}, res, () => {}); }); - it('rejects non-string name', (done) => { + it('rejects non-string name', async () => { const res = {send: (_httpCode, _data) => {}}; - serviceMethod({params: {id: accessKeyId, name: Number('9876')}}, res, (error) => { + await serviceMethod({params: {id: accessKeyId, name: Number('9876')}}, res, (error) => { expect(error.statusCode).toEqual(400); responseProcessed = true; // required for afterEach to pass. - done(); }); }); - it('defined name is equal to stored', (done) => { + it('defined name is equal to stored', async () => { const ACCESSKEY_NAME = 'accesskeyname'; const res = { send: (httpCode, data) => { @@ -441,9 +448,9 @@ describe('ShadowsocksManagerService', () => { responseProcessed = true; // required for afterEach to pass. }, }; - serviceMethod({params: {id: accessKeyId, name: ACCESSKEY_NAME}}, res, done); + await serviceMethod({params: {id: accessKeyId, name: ACCESSKEY_NAME}}, res, () => {}); }); - it('limit can be undefined', (done) => { + it('limit can be undefined', async () => { const res = { send: (httpCode, data) => { expect(httpCode).toEqual(201); @@ -451,19 +458,18 @@ describe('ShadowsocksManagerService', () => { responseProcessed = true; // required for afterEach to pass. }, }; - serviceMethod({params: {id: accessKeyId}}, res, done); + await serviceMethod({params: {id: accessKeyId}}, res, () => {}); }); - it('rejects non-numeric limits', (done) => { + it('rejects non-numeric limits', async () => { const ACCESSKEY_LIMIT = {bytes: '9876'}; const res = {send: (_httpCode, _data) => {}}; - serviceMethod({params: {id: accessKeyId, limit: ACCESSKEY_LIMIT}}, res, (error) => { + await serviceMethod({params: {id: accessKeyId, limit: ACCESSKEY_LIMIT}}, res, (error) => { expect(error.statusCode).toEqual(400); responseProcessed = true; // required for afterEach to pass. - done(); }); }); - it('defined limit is equal to stored', (done) => { + it('defined limit is equal to stored', async () => { const ACCESSKEY_LIMIT = {bytes: 9876}; const res = { send: (httpCode, data) => { @@ -472,35 +478,32 @@ describe('ShadowsocksManagerService', () => { responseProcessed = true; // required for afterEach to pass. }, }; - serviceMethod({params: {id: accessKeyId, limit: ACCESSKEY_LIMIT}}, res, done); + await serviceMethod({params: {id: accessKeyId, limit: ACCESSKEY_LIMIT}}, res, () => {}); }); - it('method must be of type string', (done) => { + it('method must be of type string', async () => { const res = {send: (_httpCode, _data) => {}}; - serviceMethod({params: {id: accessKeyId, method: Number('9876')}}, res, (error) => { + await serviceMethod({params: {id: accessKeyId, method: Number('9876')}}, res, (error) => { expect(error.statusCode).toEqual(400); responseProcessed = true; // required for afterEach to pass. - done(); }); }); - it('method must be valid', (done) => { + it('method must be valid', async () => { const res = {send: (_httpCode, _data) => {}}; - serviceMethod({params: {id: accessKeyId, method: 'abcdef'}}, res, (error) => { + await serviceMethod({params: {id: accessKeyId, method: 'abcdef'}}, res, (error) => { expect(error.statusCode).toEqual(400); responseProcessed = true; // required for afterEach to pass. - done(); }); }); - it('Create returns a 500 when the repository throws an exception', (done) => { + it('Create returns a 500 when the repository throws an exception', async () => { spyOn(repo, 'createNewAccessKey').and.throwError('cannot write to disk'); const res = {send: (_httpCode, _data) => {}}; - serviceMethod({params: {id: accessKeyId, method: 'aes-192-gcm'}}, res, (error) => { + await serviceMethod({params: {id: accessKeyId, method: 'aes-192-gcm'}}, res, (error) => { expect(error.statusCode).toEqual(500); responseProcessed = true; // required for afterEach to pass. - done(); }); }); - it('generates a new password when no password is provided', async (done) => { + it('generates a new password when no password is provided', async () => { const res = { send: (httpCode, data) => { expect(httpCode).toEqual(201); @@ -508,10 +511,10 @@ describe('ShadowsocksManagerService', () => { responseProcessed = true; // required for afterEach to pass. }, }; - await serviceMethod({params: {id: accessKeyId}}, res, done); + await serviceMethod({params: {id: accessKeyId}}, res, () => {}); }); - it('uses the provided password when one is provided', async (done) => { + it('uses the provided password when one is provided', async () => { const PASSWORD = '8iu8V8EeoFVpwQvQeS9wiD'; const res = { send: (httpCode, data) => { @@ -520,29 +523,27 @@ describe('ShadowsocksManagerService', () => { responseProcessed = true; // required for afterEach to pass. }, }; - await serviceMethod({params: {id: accessKeyId, password: PASSWORD}}, res, done); + await serviceMethod({params: {id: accessKeyId, password: PASSWORD}}, res, () => {}); }); - it('rejects a password that is not a string', async (done) => { + it('rejects a password that is not a string', async () => { const PASSWORD = Number.MAX_SAFE_INTEGER; const res = {send: SEND_NOTHING}; await serviceMethod({params: {id: accessKeyId, password: PASSWORD}}, res, (error) => { expect(error.statusCode).toEqual(400); responseProcessed = true; // required for afterEach to pass. - done(); }); }); - it('rejects a password that is already in use', async (done) => { + it('rejects a password that is already in use', async () => { const PASSWORD = 'foobar'; await repo.createNewAccessKey({password: PASSWORD}); const res = {send: SEND_NOTHING}; await serviceMethod({params: {id: accessKeyId, password: PASSWORD}}, res, (error) => { expect(error.statusCode).toEqual(409); responseProcessed = true; // required for afterEach to pass. - done(); }); }); - it('uses the default port for new keys when no port is provided', async (done) => { + it('uses the default port for new keys when no port is provided', async () => { const res = { send: (httpCode, data) => { expect(httpCode).toEqual(201); @@ -550,10 +551,10 @@ describe('ShadowsocksManagerService', () => { responseProcessed = true; // required for afterEach to pass. }, }; - await serviceMethod({params: {id: accessKeyId}}, res, done); + await serviceMethod({params: {id: accessKeyId}}, res, () => {}); }); - it('uses the provided port when one is provided', async (done) => { + it('uses the provided port when one is provided', async () => { const res = { send: (httpCode, data) => { expect(httpCode).toEqual(201); @@ -561,36 +562,36 @@ describe('ShadowsocksManagerService', () => { responseProcessed = true; // required for afterEach to pass. }, }; - await serviceMethod({params: {id: accessKeyId, port: NEW_PORT}}, res, done); + await serviceMethod({params: {id: accessKeyId, port: NEW_PORT}}, res, () => {}); }); - it('rejects ports that are not numbers', async (done) => { + it('rejects ports that are not numbers', async () => { const res = {send: SEND_NOTHING}; await serviceMethod({params: {id: accessKeyId, port: '1234'}}, res, (error) => { expect(error.statusCode).toEqual(400); responseProcessed = true; // required for afterEach to pass. - done(); }); }); - it('rejects invalid port numbers', async (done) => { + it('rejects invalid port numbers', async () => { const res = {send: SEND_NOTHING}; await serviceMethod({params: {id: accessKeyId, port: 1.4}}, res, (error) => { expect(error.statusCode).toEqual(400); responseProcessed = true; // required for afterEach to pass. - done(); }); }); - it('rejects port numbers already in use', async (done) => { + it('rejects port numbers already in use', async () => { const server = new net.Server(); - server.listen(NEW_PORT, async () => { - const res = {send: SEND_NOTHING}; - await serviceMethod({params: {id: accessKeyId, port: NEW_PORT}}, res, (error) => { - expect(error.statusCode).toEqual(409); - responseProcessed = true; // required for afterEach to pass. - server.close(); - done(); + await new Promise((resolve) => { + server.listen(NEW_PORT, async () => { + const res = {send: SEND_NOTHING}; + await serviceMethod({params: {id: accessKeyId, port: NEW_PORT}}, res, (error) => { + expect(error.statusCode).toEqual(409); + responseProcessed = true; // required for afterEach to pass. + server.close(); + resolve(); + }); }); }); }); @@ -598,7 +599,7 @@ describe('ShadowsocksManagerService', () => { } }); describe('setPortForNewAccessKeys', () => { - it('changes ports for new access keys', async (done) => { + it('changes ports for new access keys', async () => { const repo = getAccessKeyRepository(); const serverConfig = new InMemoryConfig({} as ServerConfigJson); const service = new ShadowsocksManagerServiceBuilder() @@ -617,10 +618,9 @@ describe('ShadowsocksManagerService', () => { expect(newKey.proxyParams.portNumber).toEqual(NEW_PORT); expect(oldKey.proxyParams.portNumber).not.toEqual(NEW_PORT); responseProcessed = true; - done(); }); - it('changes the server config', async (done) => { + it('changes the server config', async () => { const repo = getAccessKeyRepository(); const serverConfig = new InMemoryConfig({} as ServerConfigJson); const service = new ShadowsocksManagerServiceBuilder() @@ -635,10 +635,10 @@ describe('ShadowsocksManagerService', () => { responseProcessed = true; }, }; - await service.setPortForNewAccessKeys({params: {port: NEW_PORT}}, res, done); + await service.setPortForNewAccessKeys({params: {port: NEW_PORT}}, res, () => {}); }); - it('rejects invalid port numbers', async (done) => { + it('rejects invalid port numbers', async () => { const repo = getAccessKeyRepository(); const serverConfig = new InMemoryConfig({} as ServerConfigJson); const service = new ShadowsocksManagerServiceBuilder() @@ -664,10 +664,9 @@ describe('ShadowsocksManagerService', () => { await service.setPortForNewAccessKeys({params: {port: 65536}}, res, next); responseProcessed = true; - done(); }); - it('rejects port numbers already in use', async (done) => { + it('rejects port numbers already in use', async () => { const repo = getAccessKeyRepository(); const serverConfig = new InMemoryConfig({} as ServerConfigJson); const service = new ShadowsocksManagerServiceBuilder() @@ -686,17 +685,19 @@ describe('ShadowsocksManagerService', () => { // Conflict expect(error.statusCode).toEqual(409); responseProcessed = true; - done(); }; const server = new net.Server(); - server.listen(NEW_PORT, async () => { - await service.setPortForNewAccessKeys({params: {port: NEW_PORT}}, res, next); - server.close(); + await new Promise((resolve) => { + server.listen(NEW_PORT, async () => { + await service.setPortForNewAccessKeys({params: {port: NEW_PORT}}, res, next); + server.close(); + resolve(); + }); }); }); - it('accepts port numbers already in use by access keys', async (done) => { + it('accepts port numbers already in use by access keys', async () => { const repo = getAccessKeyRepository(); const serverConfig = new InMemoryConfig({} as ServerConfigJson); const service = new ShadowsocksManagerServiceBuilder() @@ -714,14 +715,16 @@ describe('ShadowsocksManagerService', () => { }; const firstKeyConnection = new net.Server(); - firstKeyConnection.listen(OLD_PORT, async () => { - await service.setPortForNewAccessKeys({params: {port: OLD_PORT}}, res, () => {}); - firstKeyConnection.close(); - done(); + await new Promise((resolve) => { + firstKeyConnection.listen(OLD_PORT, async () => { + await service.setPortForNewAccessKeys({params: {port: OLD_PORT}}, res, () => {}); + firstKeyConnection.close(); + resolve(); + }); }); }); - it('rejects malformed requests', async (done) => { + it('rejects malformed requests', async () => { const repo = getAccessKeyRepository(); const serverConfig = new InMemoryConfig({} as ServerConfigJson); const service = new ShadowsocksManagerServiceBuilder() @@ -752,12 +755,125 @@ describe('ShadowsocksManagerService', () => { ); responseProcessed = true; - done(); + }); + }); + + describe('setListeners', () => { + it('persists configuration and updates the Shadowsocks server', async () => { + const repo = getAccessKeyRepository(); + const serverConfig = new InMemoryConfig({} as ServerConfigJson); + const fakeServer = new FakeShadowsocksServer(); + const fakeCaddy = new FakeOutlineCaddyServer(); + const service = new ShadowsocksManagerServiceBuilder() + .serverConfig(serverConfig) + .accessKeys(repo) + .shadowsocksServer(fakeServer) + .caddyServer(fakeCaddy) + .build(); + + const listeners = { + tcp: {port: 8443}, + udp: {port: 9443}, + websocketStream: {path: '/stream', webServerPort: 8080}, + websocketPacket: {path: '/packet', webServerPort: 8080}, + }; + + const res = { + send: (httpCode) => { + expect(httpCode).toEqual(204); + responseProcessed = true; + }, + }; + + await service.setListeners({params: listeners}, res, () => {}); + + expect(serverConfig.data().listeners).toEqual(listeners); + expect(fakeServer.getListenerSettings()).toEqual({ + websocketStream: listeners.websocketStream, + websocketPacket: listeners.websocketPacket, + }); + expect(fakeCaddy.applyCalls.length).toEqual(1); + expect(fakeCaddy.applyCalls[0].listeners).toEqual(listeners); + }); + + it('clears WebSocket listener settings when they are removed', async () => { + const repo = getAccessKeyRepository(); + const serverConfig = new InMemoryConfig({} as ServerConfigJson); + const fakeServer = new FakeShadowsocksServer(); + const fakeCaddy = new FakeOutlineCaddyServer(); + const service = new ShadowsocksManagerServiceBuilder() + .serverConfig(serverConfig) + .accessKeys(repo) + .shadowsocksServer(fakeServer) + .caddyServer(fakeCaddy) + .build(); + + const listenersWithWebsocket = { + tcp: {port: 8443}, + udp: {port: 8443}, + websocketStream: {path: '/tcp', webServerPort: 8080}, + websocketPacket: {path: '/udp', webServerPort: 8080}, + }; + await service.setListeners({params: listenersWithWebsocket}, {send: () => {}}, () => {}); + expect(fakeServer.getListenerSettings()).toEqual({ + websocketStream: listenersWithWebsocket.websocketStream, + websocketPacket: listenersWithWebsocket.websocketPacket, + }); + + const listenersWithoutWebsocket = { + tcp: {port: 9090}, + udp: {port: 9090}, + }; + const res = { + send: (httpCode) => { + expect(httpCode).toEqual(204); + responseProcessed = true; + }, + }; + await service.setListeners({params: listenersWithoutWebsocket}, res, () => {}); + + expect(serverConfig.data().listeners).toEqual(listenersWithoutWebsocket); + expect(fakeServer.getListenerSettings()).toBeUndefined(); + expect(fakeCaddy.applyCalls.length).toEqual(2); + expect(fakeCaddy.applyCalls[1].listeners).toEqual(listenersWithoutWebsocket); + }); + }); + + describe('configureCaddyWebServer', () => { + it('stores configuration and applies it', async () => { + const repo = getAccessKeyRepository(); + const serverConfig = new InMemoryConfig({} as ServerConfigJson); + const fakeCaddy = new FakeOutlineCaddyServer(); + const service = new ShadowsocksManagerServiceBuilder() + .serverConfig(serverConfig) + .accessKeys(repo) + .caddyServer(fakeCaddy) + .build(); + + const config = { + enabled: true, + autoHttps: true, + email: 'admin@example.com', + domain: 'example.com', + }; + + const res = { + send: (httpCode) => { + expect(httpCode).toEqual(204); + responseProcessed = true; + }, + }; + + await service.configureCaddyWebServer({params: config}, res, () => {}); + + expect(serverConfig.data().caddyWebServer).toEqual(config); + expect(fakeCaddy.applyCalls.length).toEqual(1); + expect(fakeCaddy.applyCalls[0].caddyConfig).toEqual(config); }); }); describe('removeAccessKey', () => { - it('removes keys', async (done) => { + it('removes keys', async () => { const repo = getAccessKeyRepository(); const service = new ShadowsocksManagerServiceBuilder().accessKeys(repo).build(); const key1 = await repo.createNewAccessKey(); @@ -773,9 +889,9 @@ describe('ShadowsocksManagerService', () => { }, }; // remove the 1st key. - service.removeAccessKey({params: {id: key1.id}}, res, done); + await service.removeAccessKey({params: {id: key1.id}}, res, () => {}); }); - it('Remove returns a 500 when the repository throws an exception', async (done) => { + it('Remove returns a 500 when the repository throws an exception', async () => { const repo = getAccessKeyRepository(); spyOn(repo, 'removeAccessKey').and.throwError('cannot write to disk'); const service = new ShadowsocksManagerServiceBuilder().accessKeys(repo).build(); @@ -784,13 +900,12 @@ describe('ShadowsocksManagerService', () => { service.removeAccessKey({params: {id: key.id}}, res, (error) => { expect(error.statusCode).toEqual(500); responseProcessed = true; // required for afterEach to pass. - done(); }); }); }); describe('renameAccessKey', () => { - it('renames keys', async (done) => { + it('renames keys', async () => { const repo = getAccessKeyRepository(); const service = new ShadowsocksManagerServiceBuilder().accessKeys(repo).build(); const OLD_NAME = 'oldName'; @@ -805,9 +920,9 @@ describe('ShadowsocksManagerService', () => { responseProcessed = true; // required for afterEach to pass. }, }; - service.renameAccessKey({params: {id: key.id, name: NEW_NAME}}, res, done); + service.renameAccessKey({params: {id: key.id, name: NEW_NAME}}, res, () => {}); }); - it('Rename returns a 400 when the access key id is not a string', async (done) => { + it('Rename returns a 400 when the access key id is not a string', async () => { const repo = getAccessKeyRepository(); const service = new ShadowsocksManagerServiceBuilder().accessKeys(repo).build(); @@ -816,10 +931,9 @@ describe('ShadowsocksManagerService', () => { service.renameAccessKey({params: {id: 123}}, res, (error) => { expect(error.statusCode).toEqual(400); responseProcessed = true; // required for afterEach to pass. - done(); }); }); - it('Rename returns a 500 when the repository throws an exception', async (done) => { + it('Rename returns a 500 when the repository throws an exception', async () => { const repo = getAccessKeyRepository(); spyOn(repo, 'renameAccessKey').and.throwError('cannot write to disk'); const service = new ShadowsocksManagerServiceBuilder().accessKeys(repo).build(); @@ -829,13 +943,12 @@ describe('ShadowsocksManagerService', () => { service.renameAccessKey({params: {id: key.id, name: 'newName'}}, res, (error) => { expect(error.statusCode).toEqual(500); responseProcessed = true; // required for afterEach to pass. - done(); }); }); }); describe('setAccessKeyDataLimit', () => { - it('sets access key data limit', async (done) => { + it('sets access key data limit', async () => { const repo = getAccessKeyRepository(); const service = new ShadowsocksManagerServiceBuilder().accessKeys(repo).build(); const key = await repo.createNewAccessKey(); @@ -845,13 +958,12 @@ describe('ShadowsocksManagerService', () => { expect(httpCode).toEqual(204); expect(key.dataLimit.bytes).toEqual(1000); responseProcessed = true; - done(); }, }; service.setAccessKeyDataLimit({params: {id: key.id, limit}}, res, () => {}); }); - it('rejects negative numbers', async (done) => { + it('rejects negative numbers', async () => { const repo = getAccessKeyRepository(); const service = new ShadowsocksManagerServiceBuilder().accessKeys(repo).build(); const keyId = (await repo.createNewAccessKey()).id; @@ -859,11 +971,10 @@ describe('ShadowsocksManagerService', () => { service.setAccessKeyDataLimit({params: {id: keyId, limit}}, {send: () => {}}, (error) => { expect(error.statusCode).toEqual(400); responseProcessed = true; - done(); }); }); - it('rejects non-numeric limits', async (done) => { + it('rejects non-numeric limits', async () => { const repo = getAccessKeyRepository(); const service = new ShadowsocksManagerServiceBuilder().accessKeys(repo).build(); const keyId = (await repo.createNewAccessKey()).id; @@ -871,11 +982,10 @@ describe('ShadowsocksManagerService', () => { service.setAccessKeyDataLimit({params: {id: keyId, limit}}, {send: () => {}}, (error) => { expect(error.statusCode).toEqual(400); responseProcessed = true; - done(); }); }); - it('rejects an empty request', async (done) => { + it('rejects an empty request', async () => { const repo = getAccessKeyRepository(); const service = new ShadowsocksManagerServiceBuilder().accessKeys(repo).build(); const keyId = (await repo.createNewAccessKey()).id; @@ -883,11 +993,10 @@ describe('ShadowsocksManagerService', () => { service.setAccessKeyDataLimit({params: {id: keyId, limit}}, {send: () => {}}, (error) => { expect(error.statusCode).toEqual(400); responseProcessed = true; - done(); }); }); - it('rejects requests for nonexistent keys', async (done) => { + it('rejects requests for nonexistent keys', async () => { const repo = getAccessKeyRepository(); const service = new ShadowsocksManagerServiceBuilder().accessKeys(repo).build(); await repo.createNewAccessKey(); @@ -898,14 +1007,13 @@ describe('ShadowsocksManagerService', () => { (error) => { expect(error.statusCode).toEqual(404); responseProcessed = true; - done(); } ); }); }); describe('removeAccessKeyDataLimit', () => { - it('removes an access key data limit', async (done) => { + it('removes an access key data limit', async () => { const repo = getAccessKeyRepository(); const service = new ShadowsocksManagerServiceBuilder().accessKeys(repo).build(); const key = await repo.createNewAccessKey(); @@ -916,25 +1024,23 @@ describe('ShadowsocksManagerService', () => { expect(httpCode).toEqual(204); expect(key.dataLimit).toBeFalsy(); responseProcessed = true; - done(); }, }; service.removeAccessKeyDataLimit({params: {id: key.id}}, res, () => {}); }); - it('returns 404 for a nonexistent key', async (done) => { + it('returns 404 for a nonexistent key', async () => { const repo = getAccessKeyRepository(); const service = new ShadowsocksManagerServiceBuilder().accessKeys(repo).build(); await repo.createNewAccessKey(); service.removeAccessKeyDataLimit({params: {id: 'not an id'}}, {send: () => {}}, (error) => { expect(error.statusCode).toEqual(404); responseProcessed = true; - done(); }); }); }); describe('setDefaultDataLimit', () => { - it('sets default data limit', async (done) => { + it('sets default data limit', async () => { const serverConfig = new InMemoryConfig({} as ServerConfigJson); const repo = getAccessKeyRepository(); spyOn(repo, 'setDefaultDataLimit'); @@ -957,13 +1063,13 @@ describe('ShadowsocksManagerService', () => { responseProcessed = true; // required for afterEach to pass. }, }, - done + () => {} ); }, }; - service.setDefaultDataLimit({params: {limit}}, res, done); + service.setDefaultDataLimit({params: {limit}}, res, () => {}); }); - it('returns 400 when limit is missing values', async (done) => { + it('returns 400 when limit is missing values', async () => { const repo = getAccessKeyRepository(); const service = new ShadowsocksManagerServiceBuilder().accessKeys(repo).build(); await repo.createNewAccessKey(); @@ -972,10 +1078,9 @@ describe('ShadowsocksManagerService', () => { service.setDefaultDataLimit({params: {limit}}, res, (error) => { expect(error.statusCode).toEqual(400); responseProcessed = true; // required for afterEach to pass. - done(); }); }); - it('returns 400 when limit has negative values', async (done) => { + it('returns 400 when limit has negative values', async () => { const repo = getAccessKeyRepository(); const service = new ShadowsocksManagerServiceBuilder().accessKeys(repo).build(); await repo.createNewAccessKey(); @@ -984,10 +1089,9 @@ describe('ShadowsocksManagerService', () => { service.setDefaultDataLimit({params: {limit}}, res, (error) => { expect(error.statusCode).toEqual(400); responseProcessed = true; // required for afterEach to pass. - done(); }); }); - it('returns 500 when the repository throws an exception', async (done) => { + it('returns 500 when the repository throws an exception', async () => { const repo = getAccessKeyRepository(); spyOn(repo, 'setDefaultDataLimit').and.throwError('cannot write to disk'); const service = new ShadowsocksManagerServiceBuilder().accessKeys(repo).build(); @@ -997,13 +1101,12 @@ describe('ShadowsocksManagerService', () => { service.setDefaultDataLimit({params: {limit}}, res, (error) => { expect(error.statusCode).toEqual(500); responseProcessed = true; // required for afterEach to pass. - done(); }); }); }); describe('removeDefaultDataLimit', () => { - it('clears default data limit', async (done) => { + it('clears default data limit', async () => { const limit = {bytes: 10000}; const serverConfig = new InMemoryConfig({accessKeyDataLimit: limit} as ServerConfigJson); const repo = getAccessKeyRepository(); @@ -1021,9 +1124,9 @@ describe('ShadowsocksManagerService', () => { responseProcessed = true; // required for afterEach to pass. }, }; - service.removeDefaultDataLimit({params: {}}, res, done); + service.removeDefaultDataLimit({params: {}}, res, () => {}); }); - it('returns 500 when the repository throws an exception', async (done) => { + it('returns 500 when the repository throws an exception', async () => { const repo = getAccessKeyRepository(); spyOn(repo, 'removeDefaultDataLimit').and.throwError('cannot write to disk'); const service = new ShadowsocksManagerServiceBuilder().accessKeys(repo).build(); @@ -1032,13 +1135,12 @@ describe('ShadowsocksManagerService', () => { service.removeDefaultDataLimit({params: {id: accessKey.id}}, res, (error) => { expect(error.statusCode).toEqual(500); responseProcessed = true; // required for afterEach to pass. - done(); }); }); }); describe('getShareMetrics', () => { - it('Returns value from sharedMetrics', (done) => { + it('Returns value from sharedMetrics', () => { const sharedMetrics = fakeSharedMetricsReporter(); sharedMetrics.startSharing(); const service = new ShadowsocksManagerServiceBuilder() @@ -1053,12 +1155,12 @@ describe('ShadowsocksManagerService', () => { responseProcessed = true; }, }, - done + () => {} ); }); }); describe('setShareMetrics', () => { - it('Sets value in the config', (done) => { + it('Sets value in the config', () => { const sharedMetrics = fakeSharedMetricsReporter(); sharedMetrics.stopSharing(); const service = new ShadowsocksManagerServiceBuilder() @@ -1073,7 +1175,7 @@ describe('ShadowsocksManagerService', () => { responseProcessed = true; }, }, - done + () => {} ); }); }); @@ -1214,13 +1316,32 @@ describe('convertTimeRangeToHours', () => { }); }); +class FakeOutlineCaddyServer implements OutlineCaddyController { + applyCalls: OutlineCaddyConfigPayload[] = []; + shouldFail = false; + + async applyConfig(payload: OutlineCaddyConfigPayload): Promise { + this.applyCalls.push(payload); + if (this.shouldFail) { + throw new Error('applyConfig failure'); + } + } + + async stop(): Promise { + return Promise.resolve(); + } +} + class ShadowsocksManagerServiceBuilder { private defaultServerName_ = 'default name'; - private serverConfig_: JsonConfig = null; + private serverConfig_: JsonConfig = new InMemoryConfig( + {} as ServerConfigJson + ); private accessKeys_: AccessKeyRepository = null; private shadowsocksServer_: ShadowsocksServer = null; private managerMetrics_: ManagerMetrics = null; private metricsPublisher_: SharedMetricsPublisher = null; + private caddyServer_: OutlineCaddyController = new FakeOutlineCaddyServer(); defaultServerName(name: string): ShadowsocksManagerServiceBuilder { this.defaultServerName_ = name; @@ -1252,6 +1373,11 @@ class ShadowsocksManagerServiceBuilder { return this; } + caddyServer(server: OutlineCaddyController): ShadowsocksManagerServiceBuilder { + this.caddyServer_ = server; + return this; + } + build(): ShadowsocksManagerService { return new ShadowsocksManagerService( this.defaultServerName_, @@ -1259,7 +1385,8 @@ class ShadowsocksManagerServiceBuilder { this.accessKeys_, this.shadowsocksServer_, this.managerMetrics_, - this.metricsPublisher_ + this.metricsPublisher_, + this.caddyServer_ ); } } diff --git a/src/shadowbox/server/manager_service.ts b/src/shadowbox/server/manager_service.ts index 91bd25f4b..f145b24f8 100644 --- a/src/shadowbox/server/manager_service.ts +++ b/src/shadowbox/server/manager_service.ts @@ -20,14 +20,15 @@ import {makeConfig, SIP002_URI} from 'outline-shadowsocksconfig'; import {JsonConfig} from '../infrastructure/json_config'; import * as logging from '../infrastructure/logging'; -import {AccessKey, AccessKeyRepository, DataLimit} from '../model/access_key'; +import {AccessKey, AccessKeyRepository, DataLimit, ListenerType} from '../model/access_key'; import * as errors from '../model/errors'; import * as version from './version'; import {ManagerMetrics} from './manager_metrics'; -import {ServerConfigJson} from './server_config'; +import {ServerConfigJson, ListenersConfig, WebServerConfig} from './server_config'; import {SharedMetricsPublisher} from './shared_metrics'; import {ShadowsocksServer} from '../model/shadowsocks_server'; +import type {OutlineCaddyController} from './outline_caddy_server'; interface AccessKeyJson { // The unique identifier of this access key. @@ -35,32 +36,18 @@ interface AccessKeyJson { // Admin-controlled, editable name for this access key. name: string; // Shadowsocks-specific details and credentials. - password: string; - port: number; - method: string; - dataLimit: DataLimit; - accessUrl: string; -} - -// Creates a AccessKey response. -function accessKeyToApiJson(accessKey: AccessKey): AccessKeyJson { - return { - id: accessKey.id, - name: accessKey.name, - password: accessKey.proxyParams.password, - port: accessKey.proxyParams.portNumber, - method: accessKey.proxyParams.encryptionMethod, - dataLimit: accessKey.dataLimit, - accessUrl: SIP002_URI.stringify( - makeConfig({ - host: accessKey.proxyParams.hostname, - port: accessKey.proxyParams.portNumber, - method: accessKey.proxyParams.encryptionMethod, - password: accessKey.proxyParams.password, - outline: 1, - }) - ), - }; + // These fields are only present when the key has TCP or UDP listeners. + // For WSS-only keys, this information is in dynamicConfig instead. + password?: string; + port?: number; + method?: string; + dataLimit?: DataLimit; + accessUrl?: string; + listeners?: ListenerType[]; + // For WebSocket-enabled keys, the dynamic access configuration as a JSON object. + // This can be converted to YAML and hosted on a censorship-resistant platform. + // Compatible with Outline Client v1.15.0+. + dynamicConfig?: Record; } // Type to reflect that we receive untyped JSON request parameters. @@ -148,12 +135,22 @@ export function bindService( `${apiPrefix}/server/port-for-new-access-keys`, service.setPortForNewAccessKeys.bind(service) ); + apiServer.put(`${apiPrefix}/server/listeners`, service.setListeners.bind(service)); + apiServer.put(`${apiPrefix}/server/web-server`, service.configureCaddyWebServer.bind(service)); apiServer.post(`${apiPrefix}/access-keys`, service.createNewAccessKey.bind(service)); apiServer.put(`${apiPrefix}/access-keys/:id`, service.createAccessKey.bind(service)); apiServer.get(`${apiPrefix}/access-keys`, service.listAccessKeys.bind(service)); apiServer.get(`${apiPrefix}/access-keys/:id`, service.getAccessKey.bind(service)); + apiServer.put( + `${apiPrefix}/access-keys/:id/listeners`, + service.setAccessKeyListeners.bind(service) + ); + apiServer.get( + `${apiPrefix}/access-keys/:id/dynamic-config`, + service.getAccessKeyDynamicConfig.bind(service) + ); apiServer.del(`${apiPrefix}/access-keys/:id`, service.removeAccessKey.bind(service)); apiServer.put(`${apiPrefix}/access-keys/:id/name`, service.renameAccessKey.bind(service)); apiServer.put( @@ -273,9 +270,93 @@ export class ShadowsocksManagerService { private accessKeys: AccessKeyRepository, private shadowsocksServer: ShadowsocksServer, private managerMetrics: ManagerMetrics, - private metricsPublisher: SharedMetricsPublisher + private metricsPublisher: SharedMetricsPublisher, + private readonly caddyServer?: OutlineCaddyController ) {} + // Creates an AccessKey API response JSON object. + // For WSS-only keys, omits SS-specific fields (password, port, method, accessUrl) + // since they're redundant with dynamicConfig and not functional for direct connections. + private accessKeyToApiJson(accessKey: AccessKey): AccessKeyJson { + // Determine what types of listeners are enabled + const hasDirectListeners = + !accessKey.listeners || + accessKey.listeners.includes('tcp') || + accessKey.listeners.includes('udp'); + + const hasWebSocketListeners = + accessKey.listeners?.includes('websocket-stream') || + accessKey.listeners?.includes('websocket-packet'); + + // Start with fields that are always present + const result: AccessKeyJson = { + id: accessKey.id, + name: accessKey.name, + }; + + // Only include SS-specific fields if direct SS connections are supported + if (hasDirectListeners) { + result.password = accessKey.proxyParams.password; + result.port = accessKey.proxyParams.portNumber; + result.method = accessKey.proxyParams.encryptionMethod; + result.dataLimit = accessKey.dataLimit; + result.accessUrl = SIP002_URI.stringify( + makeConfig({ + host: accessKey.proxyParams.hostname, + port: accessKey.proxyParams.portNumber, + method: accessKey.proxyParams.encryptionMethod, + password: accessKey.proxyParams.password, + outline: 1, + }) + ); + } else if (accessKey.dataLimit) { + // For WSS-only keys, include dataLimit only when set (limits still apply server-side) + result.dataLimit = accessKey.dataLimit; + } + + // Include listeners if present + if (accessKey.listeners) { + result.listeners = accessKey.listeners; + } + + // For WebSocket-enabled keys, include the dynamic config as a JSON object + if (hasWebSocketListeners) { + const configData = this.serverConfig.data(); + const domain = configData?.caddyWebServer?.domain || configData?.hostname; + + if (domain) { + const listenersConfig = configData?.listeners; + + // Cast to access generateDynamicAccessKeyConfig method + const serverWithDynamicConfig = this.shadowsocksServer as ShadowsocksServer & { + generateDynamicAccessKeyConfig?: ( + proxyParams: {encryptionMethod: string; password: string}, + domain: string, + tcpPath: string, + udpPath: string, + tls: boolean, + listeners?: ListenerType[] + ) => Record | null; + }; + + const dynamicConfig = serverWithDynamicConfig.generateDynamicAccessKeyConfig?.( + accessKey.proxyParams, + domain, + listenersConfig?.websocketStream?.path || '/tcp', + listenersConfig?.websocketPacket?.path || '/udp', + configData?.caddyWebServer?.autoHttps !== false, + accessKey.listeners + ); + + if (dynamicConfig) { + result.dynamicConfig = dynamicConfig; + } + } + } + + return result; + } + renameServer(req: RequestType, res: ResponseType, next: restify.Next): void { logging.debug(`renameServer request ${JSON.stringify(req.params)}`); const name = req.params.name; @@ -292,29 +373,37 @@ export class ShadowsocksManagerService { ); return; } - this.serverConfig.data().name = name; + const configData = this.serverConfig.data(); + if (configData) { + configData.name = name; + } this.serverConfig.write(); res.send(HttpSuccess.NO_CONTENT); next(); } getServer(req: RequestType, res: ResponseType, next: restify.Next): void { + const configData = this.serverConfig.data(); res.send(HttpSuccess.OK, { - name: this.serverConfig.data().name || this.defaultServerName, - serverId: this.serverConfig.data().serverId, - metricsEnabled: this.serverConfig.data().metricsEnabled || false, - createdTimestampMs: this.serverConfig.data().createdTimestampMs, + name: configData?.name || this.defaultServerName, + serverId: configData?.serverId, + metricsEnabled: configData?.metricsEnabled || false, + createdTimestampMs: configData?.createdTimestampMs, version: version.getPackageVersion(), - accessKeyDataLimit: this.serverConfig.data().accessKeyDataLimit, - portForNewAccessKeys: this.serverConfig.data().portForNewAccessKeys, - hostnameForAccessKeys: this.serverConfig.data().hostname, - experimental: this.serverConfig.data().experimental, + accessKeyDataLimit: configData?.accessKeyDataLimit, + portForNewAccessKeys: configData?.portForNewAccessKeys, + hostnameForAccessKeys: configData?.hostname, + experimental: configData?.experimental, }); next(); } // Changes the server's hostname. Hostname must be a valid domain or IP address - setHostnameForAccessKeys(req: RequestType, res: ResponseType, next: restify.Next): void { + async setHostnameForAccessKeys( + req: RequestType, + res: ResponseType, + next: restify.Next + ): Promise { logging.debug(`changeHostname request: ${JSON.stringify(req.params)}`); const hostname = req.params.hostname; @@ -344,21 +433,30 @@ export class ShadowsocksManagerService { ); } - this.serverConfig.data().hostname = hostname; + const configData = this.serverConfig.data(); + if (configData) { + configData.hostname = hostname; + } this.serverConfig.write(); this.accessKeys.setHostname(hostname); + try { + await this.updateCaddyConfig(); + } catch (error) { + return next(new restifyErrors.InternalServerError(error)); + } res.send(HttpSuccess.NO_CONTENT); next(); } - // Get a access key + // Get an access key (always returns JSON) getAccessKey(req: RequestType, res: ResponseType, next: restify.Next): void { try { logging.debug(`getAccessKey request ${JSON.stringify(req.params)}`); const accessKeyId = validateAccessKeyId(req.params.id); const accessKey = this.accessKeys.getAccessKey(accessKeyId); - const accessKeyJson = accessKeyToApiJson(accessKey); + // Always return JSON - use /access-keys/{id}/dynamic-config for YAML + const accessKeyJson = this.accessKeyToApiJson(accessKey); logging.debug(`getAccessKey response ${JSON.stringify(accessKeyJson)}`); res.send(HttpSuccess.OK, accessKeyJson); return next(); @@ -376,7 +474,7 @@ export class ShadowsocksManagerService { logging.debug(`listAccessKeys request ${JSON.stringify(req.params)}`); const response = {accessKeys: []}; for (const accessKey of this.accessKeys.listAccessKeys()) { - response.accessKeys.push(accessKeyToApiJson(accessKey)); + response.accessKeys.push(this.accessKeyToApiJson(accessKey)); } logging.debug(`listAccessKeys response ${JSON.stringify(response)}`); res.send(HttpSuccess.OK, response); @@ -391,7 +489,40 @@ export class ShadowsocksManagerService { const password = validateStringParam(req.params.password, 'password'); const portNumber = validateNumberParam(req.params.port, 'port'); - const accessKeyJson = accessKeyToApiJson( + // Validate listeners if provided + let listeners = req.params.listeners as string[] | undefined; + if (listeners) { + if (!Array.isArray(listeners)) { + throw new restifyErrors.InvalidArgumentError( + {statusCode: 400}, + 'listeners must be an array' + ); + } + const validListeners = ['tcp', 'udp', 'websocket-stream', 'websocket-packet']; + for (const listener of listeners) { + if (validListeners.indexOf(listener) === -1) { + throw new restifyErrors.InvalidArgumentError( + {statusCode: 400}, + `Invalid listener type: ${listener}` + ); + } + } + } else { + // If no listeners specified, use default listeners based on server config + const serverListeners = this.serverConfig.data()?.listeners; + if (serverListeners) { + listeners = []; + if (serverListeners.tcp) listeners.push('tcp'); + if (serverListeners.udp) listeners.push('udp'); + if (serverListeners.websocketStream) listeners.push('websocket-stream'); + if (serverListeners.websocketPacket) listeners.push('websocket-packet'); + } else { + // Default to TCP and UDP if nothing is configured + listeners = ['tcp', 'udp']; + } + } + + const accessKeyJson = this.accessKeyToApiJson( await this.accessKeys.createNewAccessKey({ encryptionMethod, id, @@ -399,8 +530,10 @@ export class ShadowsocksManagerService { dataLimit, password, portNumber, + listeners: listeners as ListenerType[], }) ); + await this.updateCaddyConfig(); return accessKeyJson; } catch (error) { logging.error(error); @@ -474,8 +607,182 @@ export class ShadowsocksManagerService { ); } await this.accessKeys.setPortForNewAccessKeys(port); - this.serverConfig.data().portForNewAccessKeys = port; + const configData = this.serverConfig.data(); + if (configData) { + configData.portForNewAccessKeys = port; + // Also update listeners config for backward compatibility + if (!configData.listeners) { + configData.listeners = {}; + } + configData.listeners.tcp = {port}; + configData.listeners.udp = {port}; + } + this.serverConfig.write(); + await this.updateCaddyConfig(); + res.send(HttpSuccess.NO_CONTENT); + next(); + } catch (error) { + logging.error(error); + if (error instanceof errors.InvalidPortNumber) { + return next(new restifyErrors.InvalidArgumentError({statusCode: 400}, error.message)); + } else if (error instanceof errors.PortUnavailable) { + return next(new restifyErrors.ConflictError(error.message)); + } else if (error instanceof restifyErrors.HttpError) { + return next(error); + } + return next(new restifyErrors.InternalServerError(error)); + } + } + + // Sets the listeners configuration (defaults for new keys, optionally updates all keys) + async setListeners(req: RequestType, res: ResponseType, next: restify.Next): Promise { + try { + logging.debug(`setListeners request ${JSON.stringify(req.params)}`); + + const {applyToExisting, ...listeners} = req.params as unknown as ListenersConfig & { + applyToExisting?: boolean; + }; + if (!listeners || typeof listeners !== 'object') { + return next( + new restifyErrors.InvalidArgumentError( + {statusCode: 400}, + 'Invalid listeners configuration' + ) + ); + } + + // Validate TCP listener + if (listeners.tcp) { + const tcpPort = listeners.tcp.port; + if (tcpPort !== undefined) { + if (typeof tcpPort !== 'number' || tcpPort < 1 || tcpPort > 65535) { + return next( + new restifyErrors.InvalidArgumentError({statusCode: 400}, 'Invalid TCP port') + ); + } + } + } + + // Validate UDP listener + if (listeners.udp) { + const udpPort = listeners.udp.port; + if (udpPort !== undefined) { + if (typeof udpPort !== 'number' || udpPort < 1 || udpPort > 65535) { + return next( + new restifyErrors.InvalidArgumentError({statusCode: 400}, 'Invalid UDP port') + ); + } + } + } + + // Validate WebSocket listeners + if (listeners.websocketStream) { + if (!listeners.websocketStream.path || typeof listeners.websocketStream.path !== 'string') { + return next( + new restifyErrors.InvalidArgumentError( + {statusCode: 400}, + 'WebSocket stream path is required' + ) + ); + } + if (!listeners.websocketStream.path.startsWith('/')) { + return next( + new restifyErrors.InvalidArgumentError( + {statusCode: 400}, + 'WebSocket stream path must start with /' + ) + ); + } + const wsPort = listeners.websocketStream.webServerPort; + if (wsPort !== undefined) { + if (typeof wsPort !== 'number' || wsPort < 1 || wsPort > 65535) { + return next( + new restifyErrors.InvalidArgumentError( + {statusCode: 400}, + 'Invalid WebSocket server port' + ) + ); + } + } + } + + if (listeners.websocketPacket) { + if (!listeners.websocketPacket.path || typeof listeners.websocketPacket.path !== 'string') { + return next( + new restifyErrors.InvalidArgumentError( + {statusCode: 400}, + 'WebSocket packet path is required' + ) + ); + } + if (!listeners.websocketPacket.path.startsWith('/')) { + return next( + new restifyErrors.InvalidArgumentError( + {statusCode: 400}, + 'WebSocket packet path must start with /' + ) + ); + } + const wsPacketPort = listeners.websocketPacket.webServerPort; + if (wsPacketPort !== undefined) { + if (typeof wsPacketPort !== 'number' || wsPacketPort < 1 || wsPacketPort > 65535) { + return next( + new restifyErrors.InvalidArgumentError( + {statusCode: 400}, + 'Invalid WebSocket server port' + ) + ); + } + } + } + + if ( + listeners.websocketStream?.webServerPort !== undefined && + listeners.websocketPacket?.webServerPort !== undefined && + listeners.websocketStream.webServerPort !== listeners.websocketPacket.webServerPort + ) { + return next( + new restifyErrors.InvalidArgumentError( + {statusCode: 400}, + 'WebSocket stream and packet listeners must share the same web server port' + ) + ); + } + + // Store the listeners configuration + const configData = this.serverConfig.data(); + if (configData) { + configData.listeners = listeners; + + // Update legacy portForNewAccessKeys if TCP port is set + if (listeners.tcp?.port) { + configData.portForNewAccessKeys = listeners.tcp.port; + await this.accessKeys.setPortForNewAccessKeys(listeners.tcp.port); + } + } + + // If applyToExisting is true, update all existing keys + if (applyToExisting) { + const derivedListeners: ListenerType[] = []; + if (listeners.tcp) derivedListeners.push('tcp'); + if (listeners.udp) derivedListeners.push('udp'); + if (listeners.websocketStream) derivedListeners.push('websocket-stream'); + if (listeners.websocketPacket) derivedListeners.push('websocket-packet'); + + this.accessKeys.setListenersForAllKeys(derivedListeners); + } + + // Update the underlying Shadowsocks server with the new listener defaults. + const listenerSettings = + listeners.websocketStream || listeners.websocketPacket + ? { + websocketStream: listeners.websocketStream, + websocketPacket: listeners.websocketPacket, + } + : undefined; + this.shadowsocksServer.configureListeners?.(listenerSettings); this.serverConfig.write(); + await this.updateCaddyConfig(); res.send(HttpSuccess.NO_CONTENT); next(); } catch (error) { @@ -491,12 +798,198 @@ export class ShadowsocksManagerService { } } + // Configure Caddy web server for automatic HTTPS + async configureCaddyWebServer( + req: RequestType, + res: ResponseType, + next: restify.Next + ): Promise { + try { + logging.debug(`configureCaddyWebServer request ${JSON.stringify(req.params)}`); + + const config = req.params as unknown as WebServerConfig; + if (!config || typeof config !== 'object') { + return next( + new restifyErrors.InvalidArgumentError({statusCode: 400}, 'Invalid Caddy configuration') + ); + } + + // Validate configuration + if (config.enabled !== undefined && typeof config.enabled !== 'boolean') { + return next( + new restifyErrors.InvalidArgumentError({statusCode: 400}, 'enabled must be a boolean') + ); + } + + if (config.autoHttps !== undefined && typeof config.autoHttps !== 'boolean') { + return next( + new restifyErrors.InvalidArgumentError({statusCode: 400}, 'autoHttps must be a boolean') + ); + } + + if (config.email && typeof config.email !== 'string') { + return next( + new restifyErrors.InvalidArgumentError({statusCode: 400}, 'email must be a string') + ); + } + + if (config.domain && typeof config.domain !== 'string') { + return next( + new restifyErrors.InvalidArgumentError({statusCode: 400}, 'domain must be a string') + ); + } + + if (config.apiProxyPath && typeof config.apiProxyPath !== 'string') { + return next( + new restifyErrors.InvalidArgumentError({statusCode: 400}, 'apiProxyPath must be a string') + ); + } + + // Store Caddy configuration + const configData = this.serverConfig.data(); + if (configData) { + const caddyConfig: WebServerConfig = { + enabled: config.enabled ?? false, + autoHttps: config.autoHttps ?? false, + email: config.email, + domain: config.domain, + }; + if (config.apiProxyPath) { + caddyConfig.apiProxyPath = config.apiProxyPath; + } + configData.caddyWebServer = caddyConfig; + } + + this.serverConfig.write(); + await this.updateCaddyConfig(); + res.send(HttpSuccess.NO_CONTENT); + next(); + } catch (error) { + logging.error(error); + return next(new restifyErrors.InternalServerError(error)); + } + } + + // Updates listeners for a specific access key + async setAccessKeyListeners( + req: RequestType, + res: ResponseType, + next: restify.Next + ): Promise { + try { + logging.debug(`setAccessKeyListeners request ${JSON.stringify(req.params)}`); + const accessKeyId = validateAccessKeyId(req.params.id); + + const listenersParam = req.params.listeners as string[] | undefined; + if (!listenersParam || !Array.isArray(listenersParam)) { + return next( + new restifyErrors.InvalidArgumentError({statusCode: 400}, 'listeners must be an array') + ); + } + + const validListeners = ['tcp', 'udp', 'websocket-stream', 'websocket-packet']; + for (const listener of listenersParam) { + if (!validListeners.includes(listener)) { + return next( + new restifyErrors.InvalidArgumentError( + {statusCode: 400}, + `Invalid listener type: ${listener}` + ) + ); + } + } + + this.accessKeys.setAccessKeyListeners(accessKeyId, listenersParam as ListenerType[]); + await this.updateCaddyConfig(); + res.send(HttpSuccess.NO_CONTENT); + return next(); + } catch (error) { + logging.error(error); + if (error instanceof errors.AccessKeyNotFound) { + return next(new restifyErrors.NotFoundError(error.message)); + } + return next(new restifyErrors.InternalServerError()); + } + } + + // Returns dynamic config YAML for WebSocket-enabled keys + getAccessKeyDynamicConfig(req: RequestType, res: ResponseType, next: restify.Next): void { + try { + logging.debug(`getAccessKeyDynamicConfig request ${JSON.stringify(req.params)}`); + const accessKeyId = validateAccessKeyId(req.params.id); + const accessKey = this.accessKeys.getAccessKey(accessKeyId); + + const hasWebSocketListeners = + accessKey.listeners && + (accessKey.listeners.includes('websocket-stream') || + accessKey.listeners.includes('websocket-packet')); + + if (!hasWebSocketListeners) { + return next( + new restifyErrors.NotFoundError('Access key has no WebSocket listeners configured') + ); + } + + const configData = this.serverConfig.data(); + const domain = configData?.caddyWebServer?.domain || configData?.hostname; + + if (!domain) { + return next( + new restifyErrors.InternalServerError('No domain configured for WebSocket URLs') + ); + } + + const listenersConfig = configData?.listeners; + const serverWithWebSocket = this.shadowsocksServer as ShadowsocksServer & { + generateDynamicAccessKeyYaml?: ( + proxyParams: {encryptionMethod: string; password: string}, + domain: string, + tcpPath: string, + udpPath: string, + tls: boolean, + listeners?: ListenerType[] + ) => string | null; + }; + + const yamlConfig = serverWithWebSocket.generateDynamicAccessKeyYaml?.( + accessKey.proxyParams, + domain, + listenersConfig?.websocketStream?.path || '/tcp', + listenersConfig?.websocketPacket?.path || '/udp', + configData?.caddyWebServer?.autoHttps !== false, + accessKey.listeners + ); + + if (!yamlConfig) { + return next(new restifyErrors.InternalServerError('Failed to generate dynamic config')); + } + + const nodeResponse = res as unknown as { + setHeader: (name: string, value: string) => void; + statusCode: number; + write: (data: string) => void; + end: () => void; + }; + nodeResponse.setHeader('Content-Type', 'text/yaml; charset=utf-8'); + nodeResponse.statusCode = HttpSuccess.OK; + nodeResponse.write(yamlConfig); + nodeResponse.end(); + } catch (error) { + logging.error(error); + if (error instanceof errors.AccessKeyNotFound) { + return next(new restifyErrors.NotFoundError(error.message)); + } + return next(error); + } + } + // Removes an existing access key - removeAccessKey(req: RequestType, res: ResponseType, next: restify.Next): void { + async removeAccessKey(req: RequestType, res: ResponseType, next: restify.Next): Promise { try { logging.debug(`removeAccessKey request ${JSON.stringify(req.params)}`); const accessKeyId = validateAccessKeyId(req.params.id); this.accessKeys.removeAccessKey(accessKeyId); + await this.updateCaddyConfig(); res.send(HttpSuccess.NO_CONTENT); return next(); } catch (error) { @@ -585,7 +1078,10 @@ export class ShadowsocksManagerService { // Enforcement is done asynchronously in the proxy server. This is transparent to the manager // so this doesn't introduce any race conditions between the server and UI. this.accessKeys.setDefaultDataLimit(limit); - this.serverConfig.data().accessKeyDataLimit = limit; + const configData = this.serverConfig.data(); + if (configData) { + configData.accessKeyDataLimit = limit; + } this.serverConfig.write(); res.send(HttpSuccess.NO_CONTENT); return next(); @@ -604,7 +1100,10 @@ export class ShadowsocksManagerService { // Enforcement is done asynchronously in the proxy server. This is transparent to the manager // so this doesn't introduce any race conditions between the server and UI. this.accessKeys.removeDefaultDataLimit(); - delete this.serverConfig.data().accessKeyDataLimit; + const configData = this.serverConfig.data(); + if (configData) { + delete configData.accessKeyDataLimit; + } this.serverConfig.write(); res.send(HttpSuccess.NO_CONTENT); return next(); @@ -689,4 +1188,29 @@ export class ShadowsocksManagerService { res.send(HttpSuccess.NO_CONTENT); next(); } + + private async updateCaddyConfig(): Promise { + if (!this.caddyServer) { + return; + } + const configData = this.serverConfig.data(); + if (!configData) { + return; + } + try { + // Pass API port when apiProxyPath is configured + const apiPortNumber = Number(process.env.SB_API_PORT) || 8443; + const apiPort = configData.caddyWebServer?.apiProxyPath ? apiPortNumber : undefined; + await this.caddyServer.applyConfig({ + accessKeys: this.accessKeys.listAccessKeys(), + listeners: configData.listeners, + caddyConfig: configData.caddyWebServer, + hostname: configData.hostname, + apiPort, + }); + } catch (error) { + logging.error(`Failed to apply Caddy configuration: ${error}`); + throw error; + } + } } diff --git a/src/shadowbox/server/mocks/mocks.ts b/src/shadowbox/server/mocks/mocks.ts index 9183ebce8..ec2b6d144 100644 --- a/src/shadowbox/server/mocks/mocks.ts +++ b/src/shadowbox/server/mocks/mocks.ts @@ -13,7 +13,11 @@ // limitations under the License. import {PrometheusClient, QueryResultData} from '../../infrastructure/prometheus_scraper'; -import {ShadowsocksAccessKey, ShadowsocksServer} from '../../model/shadowsocks_server'; +import { + ListenerSettings, + ShadowsocksAccessKey, + ShadowsocksServer, +} from '../../model/shadowsocks_server'; import {TextFile} from '../../infrastructure/text_file'; export class InMemoryFile implements TextFile { @@ -37,15 +41,24 @@ export class InMemoryFile implements TextFile { export class FakeShadowsocksServer implements ShadowsocksServer { private accessKeys: ShadowsocksAccessKey[] = []; + private listeners?: ListenerSettings; update(keys: ShadowsocksAccessKey[]) { this.accessKeys = keys; return Promise.resolve(); } + configureListeners(listeners: ListenerSettings | undefined) { + this.listeners = listeners; + } + getAccessKeys() { return this.accessKeys; } + + getListenerSettings() { + return this.listeners; + } } export class FakePrometheusClient implements PrometheusClient { diff --git a/src/shadowbox/server/outline_caddy_server.ts b/src/shadowbox/server/outline_caddy_server.ts new file mode 100644 index 000000000..c01e60216 --- /dev/null +++ b/src/shadowbox/server/outline_caddy_server.ts @@ -0,0 +1,374 @@ +// Copyright 2024 The Outline Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import * as child_process from 'child_process'; +import * as path from 'path'; + +import * as mkdirp from 'mkdirp'; +import * as yaml from 'js-yaml'; + +import * as file from '../infrastructure/file'; +import * as logging from '../infrastructure/logging'; +import {AccessKey, ListenerType} from '../model/access_key'; +import {WebServerConfig, ListenerConfig, ListenersConfig} from './server_config'; + +export interface OutlineCaddyConfigPayload { + accessKeys: AccessKey[]; + listeners?: ListenersConfig; + caddyConfig?: WebServerConfig; + hostname?: string; + apiPort?: number; // Internal API port for reverse proxy +} + +export interface OutlineCaddyController { + applyConfig(payload: OutlineCaddyConfigPayload): Promise; + stop(): Promise; +} + +interface WebSocketListenerSettings { + tcpPath: string; + udpPath: string; + listenPort: number; +} + +interface CaddyConfig { + logging?: unknown; + apps: Record; +} + +export class OutlineCaddyServer implements OutlineCaddyController { + private process?: child_process.ChildProcess; + private readonly restartDelayMs = 1000; + private shouldRun = false; + private currentConfigHash?: string; + + constructor( + private readonly binaryFilename: string, + private readonly configFilename: string, + private readonly verbose: boolean + ) {} + + async applyConfig(payload: OutlineCaddyConfigPayload): Promise { + const {enabled = false} = payload.caddyConfig || {}; + if (!enabled) { + await this.stop(); + this.currentConfigHash = undefined; + return; + } + + const listenerSettings = this.getWebSocketSettings( + payload.listeners?.websocketStream, + payload.listeners?.websocketPacket + ); + const websocketKeys = this.getWebSocketKeys(payload.accessKeys); + + if (websocketKeys.length === 0) { + logging.warn('Caddy web server enabled but no WebSocket-enabled access keys found.'); + } + + const configObject = this.buildConfig(payload, listenerSettings, websocketKeys); + const configYaml = yaml.dump(configObject); + if (configYaml === this.currentConfigHash) { + // No changes; nothing to do. + return; + } + + mkdirp.sync(path.dirname(this.configFilename)); + file.atomicWriteFileSync(this.configFilename, configYaml); + this.currentConfigHash = configYaml; + this.shouldRun = true; + await this.ensureStarted(); + } + + async stop(): Promise { + this.shouldRun = false; + if (!this.process) { + return; + } + const proc = this.process; + this.process = undefined; + await new Promise((resolve) => { + proc.once('exit', () => resolve()); + proc.kill('SIGTERM'); + // Fallback in case the process ignores SIGTERM. + setTimeout(() => { + if (!proc.killed) { + proc.kill('SIGKILL'); + } + }, 5000); + }); + } + + private async ensureStarted(): Promise { + if (this.process) { + return; + } + try { + await this.start(); + } catch (error) { + logging.error(`Failed to start outline-caddy: ${error}`); + throw error; + } + } + + private start(): Promise { + return new Promise((resolve, reject) => { + const args = ['run', '--config', this.configFilename, '--adapter', 'yaml', '--watch']; + logging.info(`Starting outline-caddy with command: ${this.binaryFilename} ${args.join(' ')}`); + const proc = child_process.spawn(this.binaryFilename, args, { + stdio: ['ignore', 'pipe', 'pipe'], + }); + + const onSpawnError = (error: Error) => { + if (this.process === proc) { + this.process = undefined; + } + proc.removeAllListeners(); + reject(error); + }; + + proc.once('error', onSpawnError); + proc.once('spawn', () => { + this.process = proc; + proc.off('error', onSpawnError); + resolve(); + }); + + proc.stdout?.on('data', (data: Buffer) => { + logging.info(`[outline-caddy] ${data.toString().trimEnd()}`); + }); + proc.stderr?.on('data', (data: Buffer) => { + logging.error(`[outline-caddy] ${data.toString().trimEnd()}`); + }); + + proc.on('exit', (code, signal) => { + this.process = undefined; + const message = `outline-caddy exited with code ${code}, signal ${signal}`; + if (this.shouldRun) { + logging.warn(`${message}. Restarting.`); + setTimeout(() => { + if (this.shouldRun) { + this.start().catch((error) => { + logging.error(`Failed to restart outline-caddy: ${error}`); + }); + } + }, this.restartDelayMs); + } else { + logging.info(message); + } + }); + }); + } + + private getWebSocketKeys(accessKeys: AccessKey[]) { + return accessKeys + .filter((key) => { + const listeners = key.listeners || []; + return ( + listeners.includes('websocket-stream' as ListenerType) || + listeners.includes('websocket-packet' as ListenerType) + ); + }) + .map((key) => ({ + id: key.id, + cipher: key.proxyParams.encryptionMethod, + secret: key.proxyParams.password, + listeners: key.listeners || [], + })); + } + + private getWebSocketSettings( + streamListener?: ListenerConfig, + packetListener?: ListenerConfig + ): WebSocketListenerSettings { + const tcpPath = this.normalisePath(streamListener?.path ?? '/tcp'); + const udpPath = this.normalisePath(packetListener?.path ?? '/udp'); + const listenPort = streamListener?.webServerPort ?? packetListener?.webServerPort ?? 8080; + return {tcpPath, udpPath, listenPort}; + } + + private normalisePath(pathValue: string): string { + if (!pathValue.startsWith('/')) { + return `/${pathValue}`; + } + return pathValue; + } + + private buildConfig( + payload: OutlineCaddyConfigPayload, + listenerSettings: WebSocketListenerSettings, + websocketKeys: Array<{id: string; cipher: string; secret: string; listeners: ListenerType[]}> + ): CaddyConfig { + const {caddyConfig, hostname} = payload; + const requestedDomain = caddyConfig?.domain?.trim() || hostname; + let autoHttps = !!caddyConfig?.autoHttps; + if (autoHttps && !requestedDomain) { + logging.warn('Caddy auto HTTPS requested but no domain configured; disabling auto HTTPS.'); + autoHttps = false; + } + const domain = requestedDomain; + const listenAddresses = autoHttps ? [':80', ':443'] : [`:${listenerSettings.listenPort}`]; + + const hasStreamRoute = + websocketKeys.length === 0 || + websocketKeys.some((key) => key.listeners.includes('websocket-stream')); + const hasPacketRoute = + websocketKeys.length === 0 || + websocketKeys.some((key) => key.listeners.includes('websocket-packet')); + + const routes = []; + if (hasStreamRoute) { + routes.push(this.buildWebsocketRoute(listenerSettings.tcpPath, 'stream', domain)); + } + if (hasPacketRoute) { + routes.push(this.buildWebsocketRoute(listenerSettings.udpPath, 'packet', domain)); + } + + // Add API proxy route if configured + if (caddyConfig?.apiProxyPath && payload.apiPort) { + routes.push(this.buildApiProxyRoute(caddyConfig.apiProxyPath, payload.apiPort, domain)); + } + + const connectionHandler = { + name: 'outline-ws', + handle: { + handler: 'shadowsocks', + keys: websocketKeys.map((key) => ({ + id: key.id, + cipher: key.cipher, + secret: key.secret, + })), + }, + }; + + const httpServer: Record = { + listen: listenAddresses, + routes, + trusted_proxies: { + source: 'static', + ranges: ['127.0.0.1', '::1'], + }, + client_ip_headers: [ + 'X-Forwarded-For', + 'X-Original-Forwarded-For', + 'Forwarded-For', + 'Forwarded', + 'Client-IP', + 'CF-Connecting-IP', + 'X-Real-IP', + 'X-Client-IP', + 'True-Client-IP', + ], + }; + + if (!autoHttps) { + httpServer['automatic_https'] = {disable: true}; + } + + const apps: Record = { + outline: { + shadowsocks: { + replay_history: 10000, + }, + connection_handlers: [connectionHandler], + }, + http: { + servers: { + 'outline-websocket': httpServer, + }, + }, + }; + + if (autoHttps && domain) { + apps['tls'] = { + automation: { + policies: [ + { + subjects: [domain], + issuers: [ + { + module: 'acme', + ...(caddyConfig?.email ? {email: caddyConfig.email} : {}), + }, + ], + }, + ], + }, + }; + } + + if (this.verbose) { + return { + logging: { + logs: { + default: { + level: 'DEBUG', + }, + }, + }, + apps, + }; + } + + return {apps}; + } + + private buildWebsocketRoute(pathValue: string, type: 'stream' | 'packet', domain?: string) { + const match: Record = { + path: [pathValue], + }; + if (domain) { + match['host'] = [domain]; + } + return { + match: [match], + handle: [ + { + handler: 'websocket2layer4', + type, + connection_handler: 'outline-ws', + }, + ], + }; + } + + private buildApiProxyRoute(pathPrefix: string, apiPort: number, domain?: string) { + const normalizedPrefix = this.normalisePath(pathPrefix); + const match: Record = { + path: [`${normalizedPrefix}/*`], + }; + if (domain) { + match['host'] = [domain]; + } + return { + match: [match], + handle: [ + { + handler: 'rewrite', + strip_path_prefix: normalizedPrefix, + }, + { + handler: 'reverse_proxy', + upstreams: [{dial: `localhost:${apiPort}`}], + transport: { + protocol: 'http', + tls: { + insecure_skip_verify: true, // Skip verification for self-signed cert + }, + }, + }, + ], + }; + } +} diff --git a/src/shadowbox/server/outline_shadowsocks_server.ts b/src/shadowbox/server/outline_shadowsocks_server.ts index e7c403701..40340d2bc 100644 --- a/src/shadowbox/server/outline_shadowsocks_server.ts +++ b/src/shadowbox/server/outline_shadowsocks_server.ts @@ -19,7 +19,54 @@ import * as path from 'path'; import * as file from '../infrastructure/file'; import * as logging from '../infrastructure/logging'; -import {ShadowsocksAccessKey, ShadowsocksServer} from '../model/shadowsocks_server'; +import {ListenerType} from '../model/access_key'; +import { + ListenerSettings, + ShadowsocksAccessKey, + ShadowsocksServer, +} from '../model/shadowsocks_server'; + +// Extended interface for access keys with listeners +export interface ShadowsocksAccessKeyWithListeners extends ShadowsocksAccessKey { + listeners?: string[]; +} + +// Configuration types for outline-ss-server +interface LegacyConfig { + keys: ShadowsocksAccessKey[]; +} + +interface WebSocketListener { + type: 'websocket-stream' | 'websocket-packet'; + web_server: string; + path: string; +} + +interface TcpUdpListener { + type: 'tcp' | 'udp'; + address: string; +} + +interface ServiceConfig { + listeners: Array; + keys: Array<{ + id: string; + cipher: string; + secret: string; + }>; +} + +interface WebSocketConfig { + web?: { + servers: Array<{ + id: string; + listen: string[]; + }>; + }; + services: ServiceConfig[]; +} + +type ServerConfig = LegacyConfig | WebSocketConfig; // Runs outline-ss-server. export class OutlineShadowsocksServer implements ShadowsocksServer { @@ -28,6 +75,7 @@ export class OutlineShadowsocksServer implements ShadowsocksServer { private ipAsnFilename?: string; private isAsnMetricsEnabled = false; private isReplayProtectionEnabled = false; + private listenerSettings: ListenerSettings = {}; /** * @param binaryFilename The location for the outline-ss-server binary. @@ -65,6 +113,48 @@ export class OutlineShadowsocksServer implements ShadowsocksServer { return this; } + /** + * Configures WebSocket support for the Shadowsocks server. + * @param webServerPort The port for the internal WebSocket server to listen on. + * @param tcpPath Optional path to expose TCP over WebSocket. + * @param udpPath Optional path to expose UDP over WebSocket. + */ + configureWebSocket( + webServerPort: number, + tcpPath = '/tcp', + udpPath = '/udp' + ): OutlineShadowsocksServer { + return this.configureListeners({ + websocketStream: {webServerPort, path: tcpPath}, + websocketPacket: {webServerPort, path: udpPath}, + }); + } + + configureListeners(listeners: ListenerSettings | undefined): OutlineShadowsocksServer { + if (!listeners) { + this.listenerSettings = {}; + return this; + } + + const stream = listeners.websocketStream ? {...listeners.websocketStream} : undefined; + const packet = listeners.websocketPacket ? {...listeners.websocketPacket} : undefined; + + // If only one listener specifies the web server port, share it across both listeners. + const sharedPort = stream?.webServerPort ?? packet?.webServerPort; + if (stream && sharedPort !== undefined && stream.webServerPort === undefined) { + stream.webServerPort = sharedPort; + } + if (packet && sharedPort !== undefined && packet.webServerPort === undefined) { + packet.webServerPort = sharedPort; + } + + this.listenerSettings = { + websocketStream: stream, + websocketPacket: packet, + }; + return this; + } + // Promise is resolved after the outline-ss-config config is updated and the SIGHUP sent. // Keys may not be active yet. // TODO(fortuna): Make promise resolve when keys are ready. @@ -81,22 +171,50 @@ export class OutlineShadowsocksServer implements ShadowsocksServer { private writeConfigFile(keys: ShadowsocksAccessKey[]): Promise { return new Promise((resolve, reject) => { - const keysJson = {keys: [] as ShadowsocksAccessKey[]}; - for (const key of keys) { - if (!isAeadCipher(key.cipher)) { - logging.error( - `Cipher ${key.cipher} for access key ${key.id} is not supported: use an AEAD cipher instead.` - ); - continue; + // Check if any key has WebSocket listeners + const extendedKeys = keys as ShadowsocksAccessKeyWithListeners[]; + + // Debug logging + logging.info(`Writing config for ${keys.length} keys`); + extendedKeys.forEach((key) => { + if (key.listeners) { + logging.info(`Key ${key.id} has listeners: ${JSON.stringify(key.listeners)}`); } + }); - keysJson.keys.push(key); + const hasWebSocketKeys = extendedKeys.some( + (key) => + key.listeners && + (key.listeners.indexOf('websocket-stream') !== -1 || + key.listeners.indexOf('websocket-packet') !== -1) + ); + + logging.info(`WebSocket keys detected: ${hasWebSocketKeys}`); + + let config: ServerConfig; + + if (hasWebSocketKeys) { + // Use new format with WebSocket support + config = this.generateWebSocketConfig(extendedKeys); + } else { + // Use legacy format for backward compatibility + const keysJson = {keys: [] as ShadowsocksAccessKey[]}; + for (const key of keys) { + if (!isAeadCipher(key.cipher)) { + logging.error( + `Cipher ${key.cipher} for access key ${key.id} is not supported: use an AEAD cipher instead.` + ); + continue; + } + keysJson.keys.push(key); + } + config = keysJson; } mkdirp.sync(path.dirname(this.configFilename)); try { - file.atomicWriteFileSync(this.configFilename, jsyaml.safeDump(keysJson, {sortKeys: true})); + file.atomicWriteFileSync(this.configFilename, jsyaml.safeDump(config, {sortKeys: true})); resolve(); } catch (error) { reject(error); @@ -104,6 +222,250 @@ export class OutlineShadowsocksServer implements ShadowsocksServer { }); } + private getWebSocketSettings() { + const stream = this.listenerSettings.websocketStream ?? {}; + const packet = this.listenerSettings.websocketPacket ?? {}; + const webServerPort = stream.webServerPort ?? packet.webServerPort ?? 8080; + const tcpPath = stream.path ?? '/tcp'; + const udpPath = packet.path ?? '/udp'; + return {webServerPort, tcpPath, udpPath}; + } + + private generateWebSocketConfig(keys: ShadowsocksAccessKeyWithListeners[]): WebSocketConfig { + const {webServerPort, tcpPath, udpPath} = this.getWebSocketSettings(); + const webServerId = 'outline-ws-server'; + + type ListenerDescriptor = WebSocketListener | TcpUdpListener; + const isWebSocketListener = (listener: ListenerDescriptor): listener is WebSocketListener => { + return listener.type === 'websocket-stream' || listener.type === 'websocket-packet'; + }; + + interface ServiceGroup { + listeners: ListenerDescriptor[]; + keys: ShadowsocksAccessKeyWithListeners[]; + } + + const serviceGroups = new Map(); + + for (const key of keys) { + if (!isAeadCipher(key.cipher)) { + logging.error( + `Cipher ${key.cipher} for access key ${key.id} is not supported: use an AEAD cipher instead.` + ); + continue; + } + + const listenerSet = new Set( + (key.listeners as ListenerType[] | undefined) ?? ['tcp', 'udp'] + ); + if (listenerSet.size === 0) { + listenerSet.add('tcp'); + listenerSet.add('udp'); + } + + const listenersForKey: ListenerDescriptor[] = []; + + if (listenerSet.has('tcp')) { + listenersForKey.push({ + type: 'tcp', + address: `[::]:${key.port}`, + }); + } + if (listenerSet.has('udp')) { + listenersForKey.push({ + type: 'udp', + address: `[::]:${key.port}`, + }); + } + if (listenerSet.has('websocket-stream')) { + listenersForKey.push({ + type: 'websocket-stream', + web_server: webServerId, + path: tcpPath, + }); + } + if (listenerSet.has('websocket-packet')) { + listenersForKey.push({ + type: 'websocket-packet', + web_server: webServerId, + path: udpPath, + }); + } + + if (listenersForKey.length === 0) { + logging.warn( + `Access key ${key.id} has no listeners configured; assigning default TCP/UDP listeners.` + ); + listenersForKey.push( + {type: 'tcp', address: `[::]:${key.port}`}, + {type: 'udp', address: `[::]:${key.port}`} + ); + } + + const signatureParts = listenersForKey + .map((listener) => { + if (!isWebSocketListener(listener)) { + return `${listener.type}:${listener.address}`; + } + return `${listener.type}:${listener.path}`; + }) + .sort(); + const groupKey = signatureParts.join('|'); + + if (!serviceGroups.has(groupKey)) { + const listenersClone = listenersForKey.map((listener) => ({...listener})); + serviceGroups.set(groupKey, {listeners: listenersClone as ListenerDescriptor[], keys: []}); + } + serviceGroups.get(groupKey)!.keys.push(key); + } + + const config: WebSocketConfig = { + services: [], + }; + + const needsWebServer = Array.from(serviceGroups.values()).some((group) => + group.listeners.some( + (listener) => listener.type === 'websocket-stream' || listener.type === 'websocket-packet' + ) + ); + + if (needsWebServer) { + config.web = { + servers: [ + { + id: webServerId, + listen: [`127.0.0.1:${webServerPort}`], + }, + ], + }; + } + + for (const group of serviceGroups.values()) { + const service: ServiceConfig = { + listeners: group.listeners.map((listener) => ({...listener})), + keys: group.keys.map((k) => ({ + id: k.id, + cipher: k.cipher, + secret: k.secret, + })), + }; + config.services.push(service); + } + + return config; + } + + /** + * Generates dynamic access key configuration as a JSON object for WebSocket-enabled keys. + * This object can be serialized to YAML for use with Outline Client v1.15.0+. + * @param proxyParams The proxy parameters containing cipher and password + * @param domain The WebSocket server domain + * @param tcpPath The path for TCP over WebSocket + * @param udpPath The path for UDP over WebSocket + * @param tls Whether to use TLS (wss) or not (ws) + * @param listeners Optional list of listener types to include + * @returns The configuration object, or null if no WebSocket listeners + */ + generateDynamicAccessKeyConfig( + proxyParams: {encryptionMethod: string; password: string}, + domain: string, + tcpPath: string, + udpPath: string, + tls: boolean, + listeners?: ListenerType[] + ): Record | null { + if (!domain) { + return null; + } + + const listenerSet = new Set( + listeners ?? ['websocket-stream', 'websocket-packet'] + ); + const includeStream = listenerSet.has('websocket-stream'); + const includePacket = listenerSet.has('websocket-packet'); + + if (!includeStream && !includePacket) { + logging.warn('Dynamic access key config requested without WebSocket listeners; skipping.'); + return null; + } + + const protocol = tls ? 'wss' : 'ws'; + const transportType = includeStream && includePacket ? 'tcpudp' : includeStream ? 'tcp' : 'udp'; + + const transport: Record = { + $type: transportType, + }; + + if (includeStream) { + transport['tcp'] = { + $type: 'shadowsocks', + endpoint: { + $type: 'websocket', + url: `${protocol}://${domain}${tcpPath}`, + }, + cipher: proxyParams.encryptionMethod, + secret: proxyParams.password, + }; + } + + if (includePacket) { + transport['udp'] = { + $type: 'shadowsocks', + endpoint: { + $type: 'websocket', + url: `${protocol}://${domain}${udpPath}`, + }, + cipher: proxyParams.encryptionMethod, + secret: proxyParams.password, + }; + } + + return {transport}; + } + + /** + * Generates dynamic access key YAML content for a specific access key with WebSocket support. + * @param proxyParams The proxy parameters containing cipher and password + * @param domain The WebSocket server domain + * @param tcpPath The path for TCP over WebSocket + * @param udpPath The path for UDP over WebSocket + * @param tls Whether to use TLS (wss) or not (ws) + * @param listeners Optional list of listener types to include + * @returns The YAML content as a string, or null if no WebSocket listeners + */ + generateDynamicAccessKeyYaml( + proxyParams: {encryptionMethod: string; password: string}, + domain: string, + tcpPath: string, + udpPath: string, + tls: boolean, + listeners?: ListenerType[] + ): string | null { + const config = this.generateDynamicAccessKeyConfig( + proxyParams, + domain, + tcpPath, + udpPath, + tls, + listeners + ); + + if (!config) { + return null; + } + + // Use specific YAML options to ensure proper formatting + return jsyaml.dump(config, { + indent: 2, + lineWidth: -1, // Don't wrap long lines + noRefs: true, // Don't use references + sortKeys: false, // Preserve key order + styles: { + '!!null': 'canonical', // Use ~ for null values + }, + }); + } + private start() { const commandArguments = ['-config', this.configFilename, '-metrics', this.metricsLocation]; if (this.ipCountryFilename) { diff --git a/src/shadowbox/server/server_access_key.ts b/src/shadowbox/server/server_access_key.ts index 2c5224981..235807b74 100644 --- a/src/shadowbox/server/server_access_key.ts +++ b/src/shadowbox/server/server_access_key.ts @@ -25,6 +25,7 @@ import { AccessKeyId, AccessKeyRepository, DataLimit, + ListenerType, ProxyParams, } from '../model/access_key'; import * as errors from '../model/errors'; @@ -39,6 +40,7 @@ interface AccessKeyStorageJson { port: number; encryptionMethod?: string; dataLimit?: DataLimit; + listeners?: ListenerType[]; } // The configuration file format as json. @@ -55,7 +57,8 @@ class ServerAccessKey implements AccessKey { readonly id: AccessKeyId, public name: string, readonly proxyParams: ProxyParams, - public dataLimit?: DataLimit + public dataLimit?: DataLimit, + public listeners?: ListenerType[] ) {} } @@ -76,7 +79,8 @@ function makeAccessKey(hostname: string, accessKeyJson: AccessKeyStorageJson): A accessKeyJson.id, accessKeyJson.name, proxyParams, - accessKeyJson.dataLimit + accessKeyJson.dataLimit, + accessKeyJson.listeners ); } @@ -88,6 +92,7 @@ function accessKeyToStorageJson(accessKey: AccessKey): AccessKeyStorageJson { port: accessKey.proxyParams.portNumber, encryptionMethod: accessKey.proxyParams.encryptionMethod, dataLimit: accessKey.dataLimit, + listeners: accessKey.listeners, }; } @@ -234,7 +239,8 @@ export class ServerAccessKeyRepository implements AccessKeyRepository { }; const name = params?.name ?? ''; const dataLimit = params?.dataLimit; - const accessKey = new ServerAccessKey(id, name, proxyParams, dataLimit); + const listeners = params?.listeners; + const accessKey = new ServerAccessKey(id, name, proxyParams, dataLimit, listeners); this.accessKeys.push(accessKey); this.saveAccessKeys(); await this.updateServer(); @@ -299,6 +305,20 @@ export class ServerAccessKeyRepository implements AccessKeyRepository { this.enforceAccessKeyDataLimits(); } + setAccessKeyListeners(id: AccessKeyId, listeners: ListenerType[]): void { + this.getAccessKey(id).listeners = listeners; + this.saveAccessKeys(); + this.updateServer(); + } + + setListenersForAllKeys(listeners: ListenerType[]): void { + for (const accessKey of this.accessKeys) { + accessKey.listeners = listeners; + } + this.saveAccessKeys(); + this.updateServer(); + } + // Compares access key usage with collected metrics, marking them as under or over limit. // Updates access key data usage. async enforceAccessKeyDataLimits() { @@ -330,6 +350,7 @@ export class ServerAccessKeyRepository implements AccessKeyRepository { port: key.proxyParams.portNumber, cipher: key.proxyParams.encryptionMethod, secret: key.proxyParams.password, + listeners: key.listeners, // Pass listeners to enable WebSocket config generation }; }); return this.shadowsocksServer.update(serverAccessKeys); diff --git a/src/shadowbox/server/server_config.ts b/src/shadowbox/server/server_config.ts index 1828bb48f..3c19c6fdd 100644 --- a/src/shadowbox/server/server_config.ts +++ b/src/shadowbox/server/server_config.ts @@ -17,6 +17,29 @@ import * as uuidv4 from 'uuid/v4'; import * as json_config from '../infrastructure/json_config'; import {DataLimit} from '../model/access_key'; +// Listener configuration for new access keys +export interface ListenerConfig { + port?: number; + path?: string; + webServerPort?: number; +} + +export interface ListenersConfig { + tcp?: ListenerConfig; + udp?: ListenerConfig; + websocketStream?: ListenerConfig; + websocketPacket?: ListenerConfig; +} + +// Caddy web server configuration +export interface WebServerConfig { + enabled?: boolean; + autoHttps?: boolean; + email?: string; // For ACME + domain?: string; // Domain for automatic HTTPS + apiProxyPath?: string; // Path prefix for API proxy (e.g., "/api") +} + // Serialized format for the server config. // WARNING: Renaming fields will break backwards-compatibility. export interface ServerConfigJson { @@ -30,6 +53,10 @@ export interface ServerConfigJson { createdTimestampMs?: number; // What port number should we use for new access keys? portForNewAccessKeys?: number; + // Listeners configuration for access keys + listeners?: ListenersConfig; + // Caddy web server configuration for automatic HTTPS + caddyWebServer?: WebServerConfig; // Which staged rollouts we should force enabled or disabled. rollouts?: RolloutConfigJson[]; // We don't serialize the shadowbox version, this is obtained dynamically from node. @@ -41,7 +68,7 @@ export interface ServerConfigJson { // Experimental configuration options that are expected to be short-lived. experimental?: { // Whether ASN metric annotation for Prometheus is enabled. - asnMetricsEnabled?: boolean; // DEPRECATED + asnMetricsEnabled?: boolean; // DEPRECATED }; }