├── .github ├── update_dependencies.sh └── workflows │ ├── docker.yml │ ├── lint.yml │ ├── linux.yml │ └── test.yml ├── .gitignore ├── .golangci.yml ├── .goreleaser.fury.yaml ├── .goreleaser.yaml ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── cmd └── serenity │ ├── cmd_check.go │ ├── cmd_export.go │ ├── cmd_format.go │ ├── cmd_run.go │ ├── cmd_version.go │ └── main.go ├── common ├── cachefile │ ├── cache.go │ └── subscription.go ├── metadata │ └── metadata.go └── semver │ └── version.go ├── constant ├── rule_set.go └── version.go ├── docs ├── CNAME ├── assets │ └── icon.svg ├── changelog.md ├── configuration │ ├── index.md │ ├── profile.md │ ├── shared │ │ └── rule-set.md │ ├── subscription.md │ ├── template.md │ └── user.md ├── index.md ├── index.zh.md ├── installation │ ├── build-from-source.md │ ├── build-from-source.zh.md │ ├── docker.md │ ├── docker.zh.md │ ├── package-manager.md │ ├── package-manager.zh.md │ └── tools │ │ ├── arch-install.sh │ │ ├── deb-install.sh │ │ ├── rpm-install.sh │ │ └── rpm.repo ├── support.md └── support.zh.md ├── go.mod ├── go.sum ├── mkdocs.yml ├── option ├── message.go ├── options.go └── template.go ├── release ├── config │ ├── config.json │ ├── serenity.service │ └── serenity@.service └── local │ ├── enable.sh │ ├── install.sh │ ├── install_go.sh │ ├── reinstall.sh │ ├── serenity.service │ ├── uninstall.sh │ └── update.sh ├── server ├── profile.go ├── server.go └── server_render.go ├── subscription ├── deduplication.go ├── parser │ ├── clash.go │ ├── link.go │ ├── link_shadowsocks.go │ ├── parser.go │ ├── raw.go │ ├── sing_box.go │ └── sip008.go ├── process.go └── subscription.go └── template ├── filter ├── filter.go ├── filter_1100.go ├── filter_190.go ├── filter_null_references.go ├── filter_rule.go └── filter_test.go ├── manager.go ├── render_dns.go ├── render_experimental.go ├── render_geo_resources.go ├── render_inbounds.go ├── render_outbounds.go ├── render_route.go └── template.go /.github/update_dependencies.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | PROJECTS=$(dirname "$0")/../.. 4 | go get -x github.com/sagernet/$1@$(git -C $PROJECTS/$1 rev-parse HEAD) 5 | go mod tidy 6 | -------------------------------------------------------------------------------- /.github/workflows/docker.yml: -------------------------------------------------------------------------------- 1 | name: Publish Docker Images 2 | 3 | on: 4 | release: 5 | types: 6 | - published 7 | workflow_dispatch: 8 | inputs: 9 | tag: 10 | description: "The tag version you want to build" 11 | 12 | env: 13 | REGISTRY_IMAGE: ghcr.io/sagernet/serenity 14 | 15 | jobs: 16 | build: 17 | runs-on: ubuntu-latest 18 | strategy: 19 | fail-fast: true 20 | matrix: 21 | platform: 22 | - linux/amd64 23 | - linux/arm/v6 24 | - linux/arm/v7 25 | - linux/arm64 26 | - linux/386 27 | - linux/ppc64le 28 | - linux/riscv64 29 | - linux/s390x 30 | steps: 31 | - name: Checkout 32 | uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4 33 | with: 34 | ref: ${{ steps.ref.outputs.ref }} 35 | - name: Prepare 36 | run: | 37 | platform=${{ matrix.platform }} 38 | echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV 39 | - name: Setup QEMU 40 | uses: docker/setup-qemu-action@v3 41 | - name: Setup Docker Buildx 42 | uses: docker/setup-buildx-action@v3 43 | - name: Login to GitHub Container Registry 44 | uses: docker/login-action@v3 45 | with: 46 | registry: ghcr.io 47 | username: ${{ github.repository_owner }} 48 | password: ${{ secrets.GITHUB_TOKEN }} 49 | - name: Docker meta 50 | id: meta 51 | uses: docker/metadata-action@v5 52 | with: 53 | images: ${{ env.REGISTRY_IMAGE }} 54 | - name: Build and push by digest 55 | id: build 56 | uses: docker/build-push-action@v6 57 | with: 58 | platforms: ${{ matrix.platform }} 59 | context: . 60 | build-args: | 61 | BUILDKIT_CONTEXT_KEEP_GIT_DIR=1 62 | labels: ${{ steps.meta.outputs.labels }} 63 | outputs: type=image,name=${{ env.REGISTRY_IMAGE }},push-by-digest=true,name-canonical=true,push=true 64 | - name: Export digest 65 | run: | 66 | mkdir -p /tmp/digests 67 | digest="${{ steps.build.outputs.digest }}" 68 | touch "/tmp/digests/${digest#sha256:}" 69 | - name: Upload digest 70 | uses: actions/upload-artifact@v4 71 | with: 72 | name: digests-${{ env.PLATFORM_PAIR }} 73 | path: /tmp/digests/* 74 | if-no-files-found: error 75 | retention-days: 1 76 | merge: 77 | runs-on: ubuntu-latest 78 | needs: 79 | - build 80 | steps: 81 | - name: Get commit to build 82 | id: ref 83 | run: |- 84 | if [[ -z "${{ github.event.inputs.tag }}" ]]; then 85 | ref="${{ github.ref_name }}" 86 | else 87 | ref="${{ github.event.inputs.tag }}" 88 | fi 89 | echo "ref=$ref" 90 | echo "ref=$ref" >> $GITHUB_OUTPUT 91 | # if [[ $ref == *"-"* ]]; then 92 | # latest=latest-beta 93 | # else 94 | latest=latest 95 | # fi 96 | echo "latest=$latest" 97 | echo "latest=$latest" >> $GITHUB_OUTPUT 98 | - name: Download digests 99 | uses: actions/download-artifact@v4 100 | with: 101 | path: /tmp/digests 102 | pattern: digests-* 103 | merge-multiple: true 104 | - name: Set up Docker Buildx 105 | uses: docker/setup-buildx-action@v3 106 | - name: Login to GitHub Container Registry 107 | uses: docker/login-action@v3 108 | with: 109 | registry: ghcr.io 110 | username: ${{ github.repository_owner }} 111 | password: ${{ secrets.GITHUB_TOKEN }} 112 | - name: Create manifest list and push 113 | working-directory: /tmp/digests 114 | run: | 115 | docker buildx imagetools create \ 116 | -t "${{ env.REGISTRY_IMAGE }}:${{ steps.ref.outputs.latest }}" \ 117 | -t "${{ env.REGISTRY_IMAGE }}:${{ steps.ref.outputs.ref }}" \ 118 | $(printf '${{ env.REGISTRY_IMAGE }}@sha256:%s ' *) 119 | - name: Inspect image 120 | run: | 121 | docker buildx imagetools inspect ${{ env.REGISTRY_IMAGE }}:${{ steps.ref.outputs.latest }} 122 | docker buildx imagetools inspect ${{ env.REGISTRY_IMAGE }}:${{ steps.ref.outputs.ref }} 123 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: lint 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - dev 8 | paths-ignore: 9 | - '**.md' 10 | - '.github/**' 11 | - '!.github/workflows/lint.yml' 12 | pull_request: 13 | branches: 14 | - main 15 | - dev 16 | 17 | jobs: 18 | build: 19 | name: Build 20 | runs-on: ubuntu-latest 21 | steps: 22 | - name: Checkout 23 | uses: actions/checkout@v4 24 | with: 25 | fetch-depth: 0 26 | - name: Setup Go 27 | uses: actions/setup-go@v5 28 | with: 29 | go-version: ^1.23 30 | - name: Cache go module 31 | uses: actions/cache@v4 32 | with: 33 | path: | 34 | ~/go/pkg/mod 35 | key: go-${{ hashFiles('**/go.sum') }} 36 | - name: golangci-lint 37 | uses: golangci/golangci-lint-action@v6 38 | with: 39 | version: latest -------------------------------------------------------------------------------- /.github/workflows/linux.yml: -------------------------------------------------------------------------------- 1 | name: Release to Linux repository 2 | 3 | on: 4 | release: 5 | types: 6 | - published 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout 13 | uses: actions/checkout@0ad4b8fadaa221de15dcec353f45205ec38ea70b # v4 14 | with: 15 | fetch-depth: 0 16 | - name: Setup Go 17 | uses: actions/setup-go@v5 18 | with: 19 | go-version: ^1.23 20 | - name: Extract signing key 21 | run: |- 22 | mkdir -p $HOME/.gnupg 23 | cat > $HOME/.gnupg/sagernet.key <> "$GITHUB_ENV" 26 | EOF 27 | echo "HOME=$HOME" >> "$GITHUB_ENV" 28 | - name: Publish release 29 | uses: goreleaser/goreleaser-action@v5 30 | with: 31 | distribution: goreleaser-pro 32 | version: latest 33 | args: release -f .goreleaser.fury.yaml --clean 34 | env: 35 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 36 | GORELEASER_KEY: ${{ secrets.GORELEASER_KEY }} 37 | FURY_TOKEN: ${{ secrets.FURY_TOKEN }} 38 | NFPM_KEY_PATH: ${{ env.HOME }}/.gnupg/sagernet.key 39 | NFPM_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }} 40 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - dev 8 | paths-ignore: 9 | - '**.md' 10 | - '.github/**' 11 | - '!.github/workflows/debug.yml' 12 | pull_request: 13 | branches: 14 | - main 15 | - dev 16 | 17 | jobs: 18 | build: 19 | name: Linux 20 | runs-on: ubuntu-latest 21 | steps: 22 | - name: Checkout 23 | uses: actions/checkout@v4 24 | with: 25 | fetch-depth: 0 26 | - name: Setup Go 27 | uses: actions/setup-go@v5 28 | with: 29 | go-version: ^1.23 30 | - name: Build 31 | run: | 32 | make test 33 | build_windows: 34 | name: Windows 35 | runs-on: windows-latest 36 | steps: 37 | - name: Checkout 38 | uses: actions/checkout@v4 39 | with: 40 | fetch-depth: 0 41 | - name: Setup Go 42 | uses: actions/setup-go@v5 43 | with: 44 | go-version: ^1.23 45 | continue-on-error: true 46 | - name: Build 47 | run: | 48 | make test 49 | build_darwin: 50 | name: macOS 51 | runs-on: macos-latest 52 | steps: 53 | - name: Checkout 54 | uses: actions/checkout@v4 55 | with: 56 | fetch-depth: 0 57 | - name: Setup Go 58 | uses: actions/setup-go@v5 59 | with: 60 | go-version: ^1.23 61 | continue-on-error: true 62 | - name: Build 63 | run: | 64 | make test -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.idea/ 2 | /vendor/ 3 | /*.json 4 | /*.db 5 | /site/ 6 | /bin/ 7 | /dist/ 8 | /build/ 9 | .DS_Store 10 | /serenity 11 | /serenity.exe 12 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | linters: 2 | disable-all: true 3 | enable: 4 | - gofumpt 5 | - govet 6 | - gci 7 | - staticcheck 8 | 9 | linters-settings: 10 | gci: 11 | custom-order: true 12 | sections: 13 | - standard 14 | - prefix(github.com/sagernet/) 15 | - default 16 | -------------------------------------------------------------------------------- /.goreleaser.fury.yaml: -------------------------------------------------------------------------------- 1 | project_name: serenity 2 | builds: 3 | - id: main 4 | main: ./cmd/serenity 5 | flags: 6 | - -v 7 | - -trimpath 8 | ldflags: 9 | - -X github.com/sagernet/serenity/constant.Version={{ .Version }} -s -w -buildid= 10 | env: 11 | - CGO_ENABLED=0 12 | targets: 13 | - linux_386 14 | - linux_amd64_v1 15 | - linux_arm64 16 | - linux_arm_7 17 | - linux_s390x 18 | - linux_riscv64 19 | - linux_mips64le 20 | mod_timestamp: '{{ .CommitTimestamp }}' 21 | snapshot: 22 | name_template: "{{ .Version }}.{{ .ShortCommit }}" 23 | nfpms: 24 | - &template 25 | id: package 26 | package_name: serenity 27 | file_name_template: '{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}{{ with .Arm }}v{{ . }}{{ end }}{{ with .Mips }}_{{ . }}{{ end }}{{ if not (eq .Amd64 "v1") }}{{ .Amd64 }}{{ end }}' 28 | builds: 29 | - main 30 | homepage: https://serenity.sagernet.org/ 31 | maintainer: nekohasekai 32 | description: The configuration generator for sing-box. 33 | license: GPLv3 or later 34 | formats: 35 | - deb 36 | - rpm 37 | priority: extra 38 | contents: 39 | - src: release/config/config.json 40 | dst: /etc/serenity/config.json 41 | type: config 42 | - src: release/config/serenity.service 43 | dst: /usr/lib/systemd/system/serenity.service 44 | - src: release/config/serenity@.service 45 | dst: /usr/lib/systemd/system/serenity@.service 46 | - src: LICENSE 47 | dst: /usr/share/licenses/serenity/LICENSE 48 | deb: 49 | signature: 50 | key_file: "{{ .Env.NFPM_KEY_PATH }}" 51 | fields: 52 | Bugs: https://github.com/SagerNet/sing-box/issues 53 | rpm: 54 | signature: 55 | key_file: "{{ .Env.NFPM_KEY_PATH }}" 56 | release: 57 | disable: true 58 | furies: 59 | - account: sagernet 60 | ids: 61 | - package 62 | -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | project_name: serenity 2 | builds: 3 | - id: main 4 | main: ./cmd/serenity 5 | flags: 6 | - -v 7 | - -trimpath 8 | ldflags: 9 | - -X github.com/sagernet/serenity/constant.Version={{ .Version }} -s -w -buildid= 10 | env: 11 | - CGO_ENABLED=0 12 | targets: 13 | - linux_386 14 | - linux_amd64_v1 15 | - linux_arm64 16 | - linux_arm_7 17 | - linux_s390x 18 | - linux_riscv64 19 | - linux_mips64le 20 | mod_timestamp: '{{ .CommitTimestamp }}' 21 | snapshot: 22 | name_template: "{{ .Version }}.{{ .ShortCommit }}" 23 | archives: 24 | - id: archive 25 | builds: 26 | - main 27 | - android 28 | format: tar.gz 29 | format_overrides: 30 | - goos: windows 31 | format: zip 32 | wrap_in_directory: true 33 | files: 34 | - LICENSE 35 | name_template: '{{ .ProjectName }}-{{ .Version }}-{{ .Os }}-{{ .Arch }}{{ with .Arm }}v{{ . }}{{ end }}{{ with .Mips }}_{{ . }}{{ end }}{{ if not (eq .Amd64 "v1") }}{{ .Amd64 }}{{ end }}' 36 | nfpms: 37 | - id: package 38 | package_name: serenity 39 | file_name_template: '{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}{{ with .Arm }}v{{ . }}{{ end }}{{ with .Mips }}_{{ . }}{{ end }}{{ if not (eq .Amd64 "v1") }}{{ .Amd64 }}{{ end }}' 40 | homepage: https://serenity.sagernet.org/ 41 | maintainer: nekohasekai 42 | description: The configuration generator for sing-box. 43 | license: GPLv3 or later 44 | formats: 45 | - deb 46 | - rpm 47 | - archlinux 48 | priority: extra 49 | contents: 50 | - src: release/config/config.json 51 | dst: /etc/serenity/config.json 52 | type: config 53 | - src: release/config/serenity.service 54 | dst: /usr/lib/systemd/system/serenity.service 55 | - src: release/config/serenity@.service 56 | dst: /usr/lib/systemd/system/serenity@.service 57 | - src: LICENSE 58 | dst: /usr/share/licenses/serenity/LICENSE 59 | deb: 60 | signature: 61 | key_file: "{{ .Env.NFPM_KEY_PATH }}" 62 | fields: 63 | Bugs: https://github.com/SagerNet/serenity/issues 64 | rpm: 65 | signature: 66 | key_file: "{{ .Env.NFPM_KEY_PATH }}" 67 | source: 68 | enabled: false 69 | name_template: '{{ .ProjectName }}-{{ .Version }}.source' 70 | prefix_template: '{{ .ProjectName }}-{{ .Version }}/' 71 | checksum: 72 | disable: true 73 | name_template: '{{ .ProjectName }}-{{ .Version }}.checksum' 74 | signs: 75 | - artifacts: checksum 76 | release: 77 | github: 78 | owner: SagerNet 79 | name: serenity 80 | name_template: '{{ if .IsSnapshot }}{{ nightly }}{{ else }}{{ .Version }}{{ end }}' 81 | draft: true 82 | mode: replace 83 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM --platform=$BUILDPLATFORM golang:1.23-alpine AS builder 2 | LABEL maintainer="nekohasekai " 3 | COPY . /go/src/github.com/sagernet/serenity 4 | WORKDIR /go/src/github.com/sagernet/serenity 5 | ARG TARGETOS TARGETARCH 6 | ARG GOPROXY="" 7 | ENV GOPROXY ${GOPROXY} 8 | ENV CGO_ENABLED=0 9 | ENV GOOS=$TARGETOS 10 | ENV GOARCH=$TARGETARCH 11 | RUN set -ex \ 12 | && apk add git build-base \ 13 | && export COMMIT=$(git rev-parse --short HEAD) \ 14 | && export VERSION=$(go run github.com/sagernet/sing-box/cmd/internal/read_tag@latest) \ 15 | && go build -v -trimpath \ 16 | -o /go/bin/serenity \ 17 | -ldflags "-X \"github.com/sagernet/serenity/cmd/serenity.Version=$VERSION\" -s -w -buildid=" \ 18 | ./cmd/serenity 19 | FROM --platform=$TARGETPLATFORM alpine AS dist 20 | LABEL maintainer="nekohasekai " 21 | RUN set -ex \ 22 | && apk upgrade \ 23 | && apk add bash tzdata ca-certificates \ 24 | && rm -rf /var/cache/apk/* 25 | COPY --from=builder /go/bin/serenity /usr/local/bin/serenity 26 | ENTRYPOINT ["serenity"] 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (C) 2022 by nekohasekai 2 | 3 | This program is free software: you can redistribute it and/or modify 4 | it under the terms of the GNU General Public License as published by 5 | the Free Software Foundation, either version 3 of the License, or 6 | (at your option) any later version. 7 | 8 | This program is distributed in the hope that it will be useful, 9 | but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | GNU General Public License for more details. 12 | 13 | You should have received a copy of the GNU General Public License 14 | along with this program. If not, see . 15 | 16 | In addition, no derivative work may use the name or imply association 17 | with this application without prior consent. 18 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | NAME = serenity 2 | COMMIT = $(shell git rev-parse --short HEAD) 3 | TAG = $(shell git describe --tags --always) 4 | VERSION = $(TAG:v%=%) 5 | 6 | PARAMS = -v -trimpath -ldflags "-X 'github.com/sagernet/serenity/constant.Version=$(VERSION)' -s -w -buildid=" 7 | MAIN_PARAMS = $(PARAMS) 8 | MAIN = ./cmd/serenity 9 | PREFIX ?= $(shell go env GOPATH) 10 | 11 | .PHONY: release docs 12 | 13 | build: 14 | go build $(MAIN_PARAMS) $(MAIN) 15 | 16 | install: 17 | go build -o $(PREFIX)/bin/$(NAME) $(MAIN_PARAMS) $(MAIN) 18 | 19 | fmt: 20 | @gofumpt -l -w . 21 | @gofmt -s -w . 22 | @gci write --custom-order -s standard -s "prefix(github.com/sagernet/)" -s "default" . 23 | 24 | fmt_install: 25 | go install -v mvdan.cc/gofumpt@latest 26 | go install -v github.com/daixiang0/gci@latest 27 | 28 | lint: 29 | GOOS=linux golangci-lint run ./... 30 | GOOS=android golangci-lint run ./... 31 | GOOS=windows golangci-lint run ./... 32 | GOOS=darwin golangci-lint run ./... 33 | GOOS=freebsd golangci-lint run ./... 34 | 35 | lint_install: 36 | go install -v github.com/golangci/golangci-lint/cmd/golangci-lint@latest 37 | 38 | test: 39 | go test ./... 40 | 41 | release: 42 | goreleaser release --clean --skip publish 43 | mkdir dist/release 44 | mv dist/*.tar.gz dist/*.deb dist/*.rpm dist/*.pkg.tar.zst dist/release 45 | ghr --replace --draft --prerelease -p 3 "v${VERSION}" dist/release 46 | rm -r dist/release 47 | 48 | release_repo: 49 | goreleaser release -f .goreleaser.fury.yaml --clean 50 | 51 | release_install: 52 | go install -v github.com/goreleaser/goreleaser@latest 53 | go install -v github.com/tcnksm/ghr@latest 54 | 55 | docs: 56 | mkdocs serve 57 | 58 | publish_docs: 59 | mkdocs gh-deploy -m "Update" --force --ignore-version --no-history 60 | 61 | docs_install: 62 | pip install --force-reinstall mkdocs-material=="9.*" mkdocs-static-i18n=="1.2.*" 63 | 64 | clean: 65 | rm -rf bin dist serenity 66 | rm -f $(shell go env GOPATH)/serenity 67 | 68 | update: 69 | git fetch 70 | git reset FETCH_HEAD --hard 71 | git clean -fdx -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # serenity 2 | 3 | ![test](https://github.com/sagernet/sing/actions/workflows/test.yml/badge.svg) 4 | ![lint](https://github.com/sagernet/sing/actions/workflows/lint.yml/badge.svg) 5 | 6 | The configuration generator for sing-box. 7 | 8 | ## Documentation 9 | 10 | https://serenity.sagernet.org 11 | 12 | ## License 13 | 14 | ``` 15 | Copyright (C) 2022 by nekohasekai 16 | 17 | This program is free software: you can redistribute it and/or modify 18 | it under the terms of the GNU General Public License as published by 19 | the Free Software Foundation, either version 3 of the License, or 20 | (at your option) any later version. 21 | 22 | This program is distributed in the hope that it will be useful, 23 | but WITHOUT ANY WARRANTY; without even the implied warranty of 24 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 25 | GNU General Public License for more details. 26 | 27 | You should have received a copy of the GNU General Public License 28 | along with this program. If not, see . 29 | ``` 30 | -------------------------------------------------------------------------------- /cmd/serenity/cmd_check.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/sagernet/serenity/server" 7 | "github.com/sagernet/sing-box/log" 8 | 9 | "github.com/spf13/cobra" 10 | ) 11 | 12 | var commandCheck = &cobra.Command{ 13 | Use: "check", 14 | Short: "Check configuration", 15 | Run: func(cmd *cobra.Command, args []string) { 16 | err := check() 17 | if err != nil { 18 | log.Fatal(err) 19 | } 20 | }, 21 | Args: cobra.NoArgs, 22 | } 23 | 24 | func init() { 25 | mainCommand.AddCommand(commandCheck) 26 | } 27 | 28 | func check() error { 29 | options, err := readConfigAndMerge() 30 | if err != nil { 31 | return err 32 | } 33 | ctx, cancel := context.WithCancel(globalCtx) 34 | instance, err := server.New(ctx, options) 35 | if err == nil { 36 | instance.Close() 37 | } 38 | cancel() 39 | return err 40 | } 41 | -------------------------------------------------------------------------------- /cmd/serenity/cmd_export.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "os" 6 | "os/signal" 7 | "syscall" 8 | 9 | "github.com/sagernet/serenity/common/metadata" 10 | "github.com/sagernet/serenity/common/semver" 11 | "github.com/sagernet/serenity/server" 12 | "github.com/sagernet/sing-box/log" 13 | boxOption "github.com/sagernet/sing-box/option" 14 | "github.com/sagernet/sing/common" 15 | E "github.com/sagernet/sing/common/exceptions" 16 | "github.com/sagernet/sing/common/json" 17 | 18 | "github.com/spf13/cobra" 19 | ) 20 | 21 | var ( 22 | commandExportFlagPlatform string 23 | commandExportFlagVersion string 24 | ) 25 | 26 | var commandExport = &cobra.Command{ 27 | Use: "export [profile]", 28 | Short: "Export configuration without running HTTP services", 29 | Args: cobra.RangeArgs(0, 1), 30 | Run: func(cmd *cobra.Command, args []string) { 31 | var profileName string 32 | if len(args) == 1 { 33 | profileName = args[0] 34 | } 35 | err := export(profileName) 36 | if err != nil { 37 | log.Fatal(err) 38 | } 39 | }, 40 | } 41 | 42 | func init() { 43 | mainCommand.AddCommand(commandExport) 44 | commandExport.Flags().StringVarP(&commandExportFlagPlatform, "platform", "p", "", "platform: ios, macos, tvos, android (empty by default)") 45 | commandExport.Flags().StringVarP(&commandExportFlagVersion, "version", "v", "", "sing-box version (latest by default)") 46 | } 47 | 48 | func export(profileName string) error { 49 | var ( 50 | platform metadata.Platform 51 | version *semver.Version 52 | err error 53 | ) 54 | if commandExportFlagPlatform != "" { 55 | platform, err = metadata.ParsePlatform(commandExportFlagPlatform) 56 | if err != nil { 57 | return err 58 | } 59 | } 60 | if commandExportFlagVersion != "" { 61 | version = common.Ptr(semver.ParseVersion(commandExportFlagVersion)) 62 | } 63 | 64 | options, err := readConfigAndMerge() 65 | if err != nil { 66 | return err 67 | } 68 | if disableColor { 69 | if options.Log == nil { 70 | options.Log = &boxOption.LogOptions{} 71 | } 72 | options.Log.DisableColor = true 73 | } 74 | ctx, cancel := context.WithCancel(globalCtx) 75 | instance, err := server.New(ctx, options) 76 | if err != nil { 77 | cancel() 78 | return E.Cause(err, "create service") 79 | } 80 | osSignals := make(chan os.Signal, 1) 81 | signal.Notify(osSignals, os.Interrupt, syscall.SIGTERM, syscall.SIGHUP) 82 | defer func() { 83 | signal.Stop(osSignals) 84 | close(osSignals) 85 | }() 86 | startCtx, finishStart := context.WithCancel(context.Background()) 87 | go func() { 88 | _, loaded := <-osSignals 89 | if loaded { 90 | cancel() 91 | closeMonitor(startCtx) 92 | } 93 | }() 94 | err = instance.StartHeadless() 95 | finishStart() 96 | if err != nil { 97 | cancel() 98 | return E.Cause(err, "start service") 99 | } 100 | boxOptions, err := instance.RenderHeadless(profileName, metadata.Metadata{ 101 | Platform: platform, 102 | Version: version, 103 | }) 104 | if err != nil { 105 | return err 106 | } 107 | encoder := json.NewEncoderContext(globalCtx, os.Stdout) 108 | encoder.SetIndent("", " ") 109 | err = encoder.Encode(boxOptions) 110 | if err != nil { 111 | return E.Cause(err, "encode config") 112 | } 113 | return nil 114 | } 115 | -------------------------------------------------------------------------------- /cmd/serenity/cmd_format.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "os" 6 | "path/filepath" 7 | 8 | "github.com/sagernet/sing-box/log" 9 | E "github.com/sagernet/sing/common/exceptions" 10 | "github.com/sagernet/sing/common/json" 11 | "github.com/sagernet/sing/common/json/badjson" 12 | 13 | "github.com/spf13/cobra" 14 | ) 15 | 16 | var commandFormatFlagWrite bool 17 | 18 | var commandFormat = &cobra.Command{ 19 | Use: "format", 20 | Short: "Format configuration", 21 | Run: func(cmd *cobra.Command, args []string) { 22 | err := format() 23 | if err != nil { 24 | log.Fatal(err) 25 | } 26 | }, 27 | Args: cobra.NoArgs, 28 | } 29 | 30 | func init() { 31 | commandFormat.Flags().BoolVarP(&commandFormatFlagWrite, "write", "w", false, "write result to (source) file instead of stdout") 32 | mainCommand.AddCommand(commandFormat) 33 | } 34 | 35 | func format() error { 36 | optionsList, err := readConfig() 37 | if err != nil { 38 | return err 39 | } 40 | for _, optionsEntry := range optionsList { 41 | optionsEntry.options, err = badjson.Omitempty(globalCtx, optionsEntry.options) 42 | if err != nil { 43 | return err 44 | } 45 | buffer := new(bytes.Buffer) 46 | encoder := json.NewEncoder(buffer) 47 | encoder.SetIndent("", " ") 48 | err = encoder.Encode(optionsEntry.options) 49 | if err != nil { 50 | return E.Cause(err, "encode config") 51 | } 52 | outputPath, _ := filepath.Abs(optionsEntry.path) 53 | if !commandFormatFlagWrite { 54 | if len(optionsList) > 1 { 55 | os.Stdout.WriteString(outputPath + "\n") 56 | } 57 | os.Stdout.WriteString(buffer.String() + "\n") 58 | continue 59 | } 60 | if bytes.Equal(optionsEntry.content, buffer.Bytes()) { 61 | continue 62 | } 63 | output, err := os.Create(optionsEntry.path) 64 | if err != nil { 65 | return E.Cause(err, "open output") 66 | } 67 | _, err = output.Write(buffer.Bytes()) 68 | output.Close() 69 | if err != nil { 70 | return E.Cause(err, "write output") 71 | } 72 | os.Stderr.WriteString(outputPath + "\n") 73 | } 74 | return nil 75 | } 76 | -------------------------------------------------------------------------------- /cmd/serenity/cmd_run.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "io" 6 | "os" 7 | "os/signal" 8 | "path/filepath" 9 | runtimeDebug "runtime/debug" 10 | "sort" 11 | "strings" 12 | "syscall" 13 | "time" 14 | 15 | "github.com/sagernet/serenity/option" 16 | "github.com/sagernet/serenity/server" 17 | C "github.com/sagernet/sing-box/constant" 18 | "github.com/sagernet/sing-box/log" 19 | boxOption "github.com/sagernet/sing-box/option" 20 | E "github.com/sagernet/sing/common/exceptions" 21 | "github.com/sagernet/sing/common/json" 22 | "github.com/sagernet/sing/common/json/badjson" 23 | 24 | "github.com/spf13/cobra" 25 | ) 26 | 27 | var commandRun = &cobra.Command{ 28 | Use: "run", 29 | Short: "Run service", 30 | Run: func(cmd *cobra.Command, args []string) { 31 | err := run() 32 | if err != nil { 33 | log.Fatal(err) 34 | } 35 | }, 36 | } 37 | 38 | func init() { 39 | mainCommand.AddCommand(commandRun) 40 | } 41 | 42 | type OptionsEntry struct { 43 | content []byte 44 | path string 45 | options option.Options 46 | } 47 | 48 | func readConfigAt(path string) (*OptionsEntry, error) { 49 | var ( 50 | configContent []byte 51 | err error 52 | ) 53 | if path == "stdin" { 54 | configContent, err = io.ReadAll(os.Stdin) 55 | } else { 56 | configContent, err = os.ReadFile(path) 57 | } 58 | if err != nil { 59 | return nil, E.Cause(err, "read config at ", path) 60 | } 61 | options, err := json.UnmarshalExtendedContext[option.Options](globalCtx, configContent) 62 | if err != nil { 63 | return nil, E.Cause(err, "decode config at ", path) 64 | } 65 | return &OptionsEntry{ 66 | content: configContent, 67 | path: path, 68 | options: options, 69 | }, nil 70 | } 71 | 72 | func readConfig() ([]*OptionsEntry, error) { 73 | var optionsList []*OptionsEntry 74 | for _, path := range configPaths { 75 | optionsEntry, err := readConfigAt(path) 76 | if err != nil { 77 | return nil, err 78 | } 79 | optionsList = append(optionsList, optionsEntry) 80 | } 81 | for _, directory := range configDirectories { 82 | entries, err := os.ReadDir(directory) 83 | if err != nil { 84 | return nil, E.Cause(err, "read config directory at ", directory) 85 | } 86 | for _, entry := range entries { 87 | if !strings.HasSuffix(entry.Name(), ".json") || entry.IsDir() { 88 | continue 89 | } 90 | optionsEntry, err := readConfigAt(filepath.Join(directory, entry.Name())) 91 | if err != nil { 92 | return nil, err 93 | } 94 | optionsList = append(optionsList, optionsEntry) 95 | } 96 | } 97 | sort.Slice(optionsList, func(i, j int) bool { 98 | return optionsList[i].path < optionsList[j].path 99 | }) 100 | return optionsList, nil 101 | } 102 | 103 | func readConfigAndMerge() (option.Options, error) { 104 | optionsList, err := readConfig() 105 | if err != nil { 106 | return option.Options{}, err 107 | } 108 | if len(optionsList) == 1 { 109 | return optionsList[0].options, nil 110 | } 111 | var mergedMessage json.RawMessage 112 | for _, options := range optionsList { 113 | mergedMessage, err = badjson.MergeJSON(globalCtx, options.options.RawMessage, mergedMessage, false) 114 | if err != nil { 115 | return option.Options{}, E.Cause(err, "merge config at ", options.path) 116 | } 117 | } 118 | var mergedOptions option.Options 119 | err = mergedOptions.UnmarshalJSONContext(globalCtx, mergedMessage) 120 | if err != nil { 121 | return option.Options{}, E.Cause(err, "unmarshal merged config") 122 | } 123 | return mergedOptions, nil 124 | } 125 | 126 | func create() (*server.Server, context.CancelFunc, error) { 127 | options, err := readConfigAndMerge() 128 | if err != nil { 129 | return nil, nil, err 130 | } 131 | if disableColor { 132 | if options.Log == nil { 133 | options.Log = &boxOption.LogOptions{} 134 | } 135 | options.Log.DisableColor = true 136 | } 137 | ctx, cancel := context.WithCancel(globalCtx) 138 | instance, err := server.New(ctx, options) 139 | if err != nil { 140 | cancel() 141 | return nil, nil, E.Cause(err, "create service") 142 | } 143 | 144 | osSignals := make(chan os.Signal, 1) 145 | signal.Notify(osSignals, os.Interrupt, syscall.SIGTERM, syscall.SIGHUP) 146 | defer func() { 147 | signal.Stop(osSignals) 148 | close(osSignals) 149 | }() 150 | startCtx, finishStart := context.WithCancel(context.Background()) 151 | go func() { 152 | _, loaded := <-osSignals 153 | if loaded { 154 | cancel() 155 | closeMonitor(startCtx) 156 | } 157 | }() 158 | err = instance.Start() 159 | finishStart() 160 | if err != nil { 161 | cancel() 162 | return nil, nil, E.Cause(err, "start service") 163 | } 164 | return instance, cancel, nil 165 | } 166 | 167 | func run() error { 168 | osSignals := make(chan os.Signal, 1) 169 | signal.Notify(osSignals, os.Interrupt, syscall.SIGTERM, syscall.SIGHUP) 170 | defer signal.Stop(osSignals) 171 | for { 172 | instance, cancel, err := create() 173 | if err != nil { 174 | return err 175 | } 176 | runtimeDebug.FreeOSMemory() 177 | for { 178 | osSignal := <-osSignals 179 | if osSignal == syscall.SIGHUP { 180 | err = check() 181 | if err != nil { 182 | log.Error(E.Cause(err, "reload service")) 183 | continue 184 | } 185 | } 186 | cancel() 187 | closeCtx, closed := context.WithCancel(context.Background()) 188 | go closeMonitor(closeCtx) 189 | instance.Close() 190 | closed() 191 | if osSignal != syscall.SIGHUP { 192 | return nil 193 | } 194 | break 195 | } 196 | } 197 | } 198 | 199 | func closeMonitor(ctx context.Context) { 200 | time.Sleep(C.FatalStopTimeout) 201 | select { 202 | case <-ctx.Done(): 203 | return 204 | default: 205 | } 206 | log.Fatal("serenity did not close!") 207 | } 208 | -------------------------------------------------------------------------------- /cmd/serenity/cmd_version.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | "runtime" 6 | "runtime/debug" 7 | 8 | C "github.com/sagernet/serenity/constant" 9 | 10 | "github.com/spf13/cobra" 11 | ) 12 | 13 | var commandVersion = &cobra.Command{ 14 | Use: "version", 15 | Short: "Print current version of serenity", 16 | Run: printVersion, 17 | Args: cobra.NoArgs, 18 | } 19 | 20 | var nameOnly bool 21 | 22 | func init() { 23 | commandVersion.Flags().BoolVarP(&nameOnly, "name", "n", false, "print version name only") 24 | mainCommand.AddCommand(commandVersion) 25 | } 26 | 27 | func printVersion(cmd *cobra.Command, args []string) { 28 | if nameOnly { 29 | os.Stdout.WriteString(C.Version + "\n") 30 | return 31 | } 32 | version := "serenity version " + C.Version + " (sing-box " + C.CoreVersion() + ")\n\n" 33 | version += "Environment: " + runtime.Version() + " " + runtime.GOOS + "/" + runtime.GOARCH + "\n" 34 | 35 | var revision string 36 | 37 | debugInfo, loaded := debug.ReadBuildInfo() 38 | if loaded { 39 | for _, setting := range debugInfo.Settings { 40 | switch setting.Key { 41 | case "vcs.revision": 42 | revision = setting.Value 43 | } 44 | } 45 | } 46 | 47 | if revision != "" { 48 | version += "Revision: " + revision + "\n" 49 | } 50 | 51 | os.Stdout.WriteString(version) 52 | } 53 | -------------------------------------------------------------------------------- /cmd/serenity/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "os" 6 | "time" 7 | 8 | "github.com/sagernet/sing-box/include" 9 | _ "github.com/sagernet/sing-box/include" 10 | "github.com/sagernet/sing-box/log" 11 | 12 | "github.com/spf13/cobra" 13 | ) 14 | 15 | var ( 16 | configPaths []string 17 | configDirectories []string 18 | workingDir string 19 | disableColor bool 20 | globalCtx context.Context 21 | ) 22 | 23 | var mainCommand = &cobra.Command{ 24 | Use: "serenity", 25 | Short: "the configuration generator for sing-box", 26 | PersistentPreRun: preRun, 27 | } 28 | 29 | func init() { 30 | mainCommand.PersistentFlags().StringArrayVarP(&configPaths, "config", "c", nil, "set configuration file path") 31 | mainCommand.PersistentFlags().StringArrayVarP(&configDirectories, "config-directory", "C", nil, "set configuration directory path") 32 | mainCommand.PersistentFlags().StringVarP(&workingDir, "directory", "D", "", "set working directory") 33 | mainCommand.PersistentFlags().BoolVarP(&disableColor, "disable-color", "", false, "disable color output") 34 | } 35 | 36 | func main() { 37 | if err := mainCommand.Execute(); err != nil { 38 | log.Fatal(err) 39 | } 40 | } 41 | 42 | func preRun(cmd *cobra.Command, args []string) { 43 | if disableColor { 44 | log.SetStdLogger(log.NewDefaultFactory(context.Background(), log.Formatter{BaseTime: time.Now(), DisableColors: true}, os.Stderr, "", nil, false).Logger()) 45 | } 46 | if workingDir != "" { 47 | _, err := os.Stat(workingDir) 48 | if err != nil { 49 | os.MkdirAll(workingDir, 0o777) 50 | } 51 | if err := os.Chdir(workingDir); err != nil { 52 | log.Fatal(err) 53 | } 54 | } 55 | if len(configPaths) == 0 && len(configDirectories) == 0 { 56 | configPaths = append(configPaths, "config.json") 57 | } 58 | globalCtx = include.Context(context.Background()) 59 | } 60 | -------------------------------------------------------------------------------- /common/cachefile/cache.go: -------------------------------------------------------------------------------- 1 | package cachefile 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "os" 7 | "time" 8 | 9 | "github.com/sagernet/bbolt" 10 | bboltErrors "github.com/sagernet/bbolt/errors" 11 | "github.com/sagernet/sing/common" 12 | E "github.com/sagernet/sing/common/exceptions" 13 | ) 14 | 15 | var ( 16 | bucketSubscription = []byte("subscription") 17 | 18 | bucketNameList = []string{ 19 | string(bucketSubscription), 20 | } 21 | ) 22 | 23 | type CacheFile struct { 24 | path string 25 | DB *bbolt.DB 26 | } 27 | 28 | func New(path string) *CacheFile { 29 | return &CacheFile{ 30 | path: path, 31 | } 32 | } 33 | 34 | func (c *CacheFile) Start() error { 35 | const fileMode = 0o666 36 | options := bbolt.Options{Timeout: time.Second} 37 | var ( 38 | db *bbolt.DB 39 | err error 40 | ) 41 | for i := 0; i < 10; i++ { 42 | db, err = bbolt.Open(c.path, fileMode, &options) 43 | if err == nil { 44 | break 45 | } 46 | if errors.Is(err, bboltErrors.ErrTimeout) { 47 | continue 48 | } 49 | if E.IsMulti(err, bboltErrors.ErrInvalid, bboltErrors.ErrChecksum, bboltErrors.ErrVersionMismatch) { 50 | rmErr := os.Remove(c.path) 51 | if rmErr != nil { 52 | return err 53 | } 54 | } 55 | time.Sleep(100 * time.Millisecond) 56 | } 57 | if err != nil { 58 | return err 59 | } 60 | err = db.Batch(func(tx *bbolt.Tx) error { 61 | return tx.ForEach(func(name []byte, b *bbolt.Bucket) error { 62 | bucketName := string(name) 63 | if !(common.Contains(bucketNameList, bucketName)) { 64 | _ = tx.DeleteBucket(name) 65 | } 66 | return nil 67 | }) 68 | }) 69 | if err != nil { 70 | db.Close() 71 | return err 72 | } 73 | c.DB = db 74 | return nil 75 | } 76 | 77 | func (c *CacheFile) Close() error { 78 | if c.DB == nil { 79 | return nil 80 | } 81 | return c.DB.Close() 82 | } 83 | 84 | func (c *CacheFile) LoadSubscription(ctx context.Context, name string) *Subscription { 85 | var subscription Subscription 86 | err := c.DB.View(func(tx *bbolt.Tx) error { 87 | bucket := tx.Bucket(bucketSubscription) 88 | if bucket == nil { 89 | return nil 90 | } 91 | data := bucket.Get([]byte(name)) 92 | if data == nil { 93 | return nil 94 | } 95 | return subscription.UnmarshalBinary(ctx, data) 96 | }) 97 | if err != nil { 98 | return nil 99 | } 100 | return &subscription 101 | } 102 | 103 | func (c *CacheFile) StoreSubscription(ctx context.Context, name string, subscription *Subscription) error { 104 | data, err := subscription.MarshalBinary(ctx) 105 | if err != nil { 106 | return err 107 | } 108 | return c.DB.Batch(func(tx *bbolt.Tx) error { 109 | bucket, err := tx.CreateBucketIfNotExists(bucketSubscription) 110 | if err != nil { 111 | return err 112 | } 113 | return bucket.Put([]byte(name), data) 114 | }) 115 | } 116 | -------------------------------------------------------------------------------- /common/cachefile/subscription.go: -------------------------------------------------------------------------------- 1 | package cachefile 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/binary" 7 | "time" 8 | 9 | "github.com/sagernet/sing-box/option" 10 | "github.com/sagernet/sing/common/json" 11 | "github.com/sagernet/sing/common/varbin" 12 | ) 13 | 14 | type Subscription struct { 15 | Content []option.Outbound 16 | LastUpdated time.Time 17 | LastEtag string 18 | } 19 | 20 | func (c *Subscription) MarshalBinary(ctx context.Context) ([]byte, error) { 21 | var buffer bytes.Buffer 22 | buffer.WriteByte(1) 23 | content, err := json.MarshalContext(ctx, c.Content) 24 | if err != nil { 25 | return nil, err 26 | } 27 | _, err = varbin.WriteUvarint(&buffer, uint64(len(content))) 28 | if err != nil { 29 | return nil, err 30 | } 31 | _, err = buffer.Write(content) 32 | if err != nil { 33 | return nil, err 34 | } 35 | err = binary.Write(&buffer, binary.BigEndian, c.LastUpdated.Unix()) 36 | if err != nil { 37 | return nil, err 38 | } 39 | err = varbin.Write(&buffer, binary.BigEndian, c.LastEtag) 40 | if err != nil { 41 | return nil, err 42 | } 43 | return buffer.Bytes(), nil 44 | } 45 | 46 | func (c *Subscription) UnmarshalBinary(ctx context.Context, data []byte) error { 47 | reader := bytes.NewReader(data) 48 | version, err := reader.ReadByte() 49 | if err != nil { 50 | return err 51 | } 52 | _ = version 53 | contentLength, err := binary.ReadUvarint(reader) 54 | if err != nil { 55 | return err 56 | } 57 | content := make([]byte, contentLength) 58 | _, err = reader.Read(content) 59 | if err != nil { 60 | return err 61 | } 62 | err = json.UnmarshalContext(ctx, content, &c.Content) 63 | if err != nil { 64 | return err 65 | } 66 | var lastUpdatedUnix int64 67 | err = binary.Read(reader, binary.BigEndian, &lastUpdatedUnix) 68 | if err != nil { 69 | return err 70 | } 71 | c.LastUpdated = time.Unix(lastUpdatedUnix, 0) 72 | err = varbin.Read(reader, binary.BigEndian, &c.LastEtag) 73 | if err != nil { 74 | return err 75 | } 76 | return nil 77 | } 78 | -------------------------------------------------------------------------------- /common/metadata/metadata.go: -------------------------------------------------------------------------------- 1 | package metadata 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/sagernet/serenity/common/semver" 7 | E "github.com/sagernet/sing/common/exceptions" 8 | ) 9 | 10 | type Platform string 11 | 12 | const ( 13 | PlatformUnknown Platform = "" 14 | PlatformAndroid Platform = "android" 15 | PlatformiOS Platform = "ios" 16 | PlatformMacOS Platform = "macos" 17 | PlatformAppleTVOS Platform = "tvos" 18 | ) 19 | 20 | func ParsePlatform(name string) (Platform, error) { 21 | switch strings.ToLower(name) { 22 | case "android": 23 | return PlatformAndroid, nil 24 | case "ios": 25 | return PlatformiOS, nil 26 | case "macos": 27 | return PlatformMacOS, nil 28 | case "tvos": 29 | return PlatformAppleTVOS, nil 30 | default: 31 | return PlatformUnknown, E.New("unknown platform: ", name) 32 | } 33 | } 34 | 35 | func (m Platform) IsApple() bool { 36 | switch m { 37 | case PlatformiOS, PlatformMacOS, PlatformAppleTVOS: 38 | return true 39 | default: 40 | return false 41 | } 42 | } 43 | 44 | func (m Platform) IsNetworkExtensionMemoryLimited() bool { 45 | switch m { 46 | case PlatformiOS, PlatformAppleTVOS: 47 | return true 48 | default: 49 | return false 50 | } 51 | } 52 | 53 | func (m Platform) TunOnly() bool { 54 | return m.IsApple() 55 | } 56 | 57 | func (m Platform) String() string { 58 | return string(m) 59 | } 60 | 61 | type Metadata struct { 62 | UserAgent string 63 | Platform Platform 64 | Version *semver.Version 65 | } 66 | 67 | func Detect(userAgent string) Metadata { 68 | var metadata Metadata 69 | metadata.UserAgent = userAgent 70 | if strings.HasPrefix(userAgent, "SFA") { 71 | metadata.Platform = PlatformAndroid 72 | } else if strings.HasPrefix(userAgent, "SFI") { 73 | metadata.Platform = PlatformiOS 74 | } else if strings.HasPrefix(userAgent, "SFM") { 75 | metadata.Platform = PlatformMacOS 76 | } else if strings.HasPrefix(userAgent, "SFT") { 77 | metadata.Platform = PlatformAppleTVOS 78 | } 79 | var versionName string 80 | if strings.Contains(userAgent, "sing-box ") { 81 | versionName = strings.Split(userAgent, "sing-box ")[1] 82 | if strings.Contains(versionName, ";") { 83 | versionName = strings.Split(versionName, ";")[0] 84 | } else { 85 | versionName = strings.Split(versionName, ")")[0] 86 | } 87 | } 88 | if semver.IsValid(versionName) { 89 | version := semver.ParseVersion(versionName) 90 | metadata.Version = &version 91 | } 92 | return metadata 93 | } 94 | -------------------------------------------------------------------------------- /common/semver/version.go: -------------------------------------------------------------------------------- 1 | package semver 2 | 3 | import ( 4 | "strconv" 5 | "strings" 6 | 7 | F "github.com/sagernet/sing/common/format" 8 | 9 | "golang.org/x/mod/semver" 10 | ) 11 | 12 | type Version struct { 13 | Major int 14 | Minor int 15 | Patch int 16 | Commit string 17 | PreReleaseIdentifier string 18 | PreReleaseVersion int 19 | } 20 | 21 | func (v Version) LessThan(anotherVersion Version) bool { 22 | return !v.GreaterThanOrEqual(anotherVersion) 23 | } 24 | 25 | func (v Version) LessThanOrEqual(anotherVersion Version) bool { 26 | return v == anotherVersion || anotherVersion.GreaterThan(v) 27 | } 28 | 29 | func (v Version) GreaterThanOrEqual(anotherVersion Version) bool { 30 | return v == anotherVersion || v.GreaterThan(anotherVersion) 31 | } 32 | 33 | func (v Version) GreaterThan(anotherVersion Version) bool { 34 | if v.Major > anotherVersion.Major { 35 | return true 36 | } else if v.Major < anotherVersion.Major { 37 | return false 38 | } 39 | if v.Minor > anotherVersion.Minor { 40 | return true 41 | } else if v.Minor < anotherVersion.Minor { 42 | return false 43 | } 44 | if v.Patch > anotherVersion.Patch { 45 | return true 46 | } else if v.Patch < anotherVersion.Patch { 47 | return false 48 | } 49 | if v.PreReleaseIdentifier == "" && anotherVersion.PreReleaseIdentifier != "" { 50 | return true 51 | } else if v.PreReleaseIdentifier != "" && anotherVersion.PreReleaseIdentifier == "" { 52 | return false 53 | } 54 | if v.PreReleaseIdentifier != "" && anotherVersion.PreReleaseIdentifier != "" { 55 | if v.PreReleaseIdentifier == anotherVersion.PreReleaseIdentifier { 56 | if v.PreReleaseVersion > anotherVersion.PreReleaseVersion { 57 | return true 58 | } else if v.PreReleaseVersion < anotherVersion.PreReleaseVersion { 59 | return false 60 | } 61 | } 62 | preReleaseIdentifier := parsePreReleaseIdentifier(v.PreReleaseIdentifier) 63 | anotherPreReleaseIdentifier := parsePreReleaseIdentifier(anotherVersion.PreReleaseIdentifier) 64 | if preReleaseIdentifier < anotherPreReleaseIdentifier { 65 | return true 66 | } else if preReleaseIdentifier > anotherPreReleaseIdentifier { 67 | return false 68 | } 69 | } 70 | return false 71 | } 72 | 73 | func parsePreReleaseIdentifier(identifier string) int { 74 | if strings.HasPrefix(identifier, "rc") { 75 | return 1 76 | } else if strings.HasPrefix(identifier, "beta") { 77 | return 2 78 | } else if strings.HasPrefix(identifier, "alpha") { 79 | return 3 80 | } 81 | return 0 82 | } 83 | 84 | func (v Version) String() string { 85 | version := F.ToString(v.Major, ".", v.Minor, ".", v.Patch) 86 | if v.PreReleaseIdentifier != "" { 87 | version = F.ToString(version, "-", v.PreReleaseIdentifier, ".", v.PreReleaseVersion) 88 | } 89 | return version 90 | } 91 | 92 | func (v Version) BadString() string { 93 | version := F.ToString(v.Major, ".", v.Minor) 94 | if v.Patch > 0 { 95 | version = F.ToString(version, ".", v.Patch) 96 | } 97 | if v.PreReleaseIdentifier != "" { 98 | version = F.ToString(version, "-", v.PreReleaseIdentifier) 99 | if v.PreReleaseVersion > 0 { 100 | version = F.ToString(version, v.PreReleaseVersion) 101 | } 102 | } 103 | return version 104 | } 105 | 106 | func IsValid(versionName string) bool { 107 | return semver.IsValid("v" + versionName) 108 | } 109 | 110 | func ParseVersion(versionName string) (version Version) { 111 | if strings.HasPrefix(versionName, "v") { 112 | versionName = versionName[1:] 113 | } 114 | if strings.Contains(versionName, "-") { 115 | parts := strings.Split(versionName, "-") 116 | versionName = parts[0] 117 | identifier := parts[1] 118 | if strings.Contains(identifier, ".") { 119 | identifierParts := strings.Split(identifier, ".") 120 | version.PreReleaseIdentifier = identifierParts[0] 121 | if len(identifierParts) >= 2 { 122 | version.PreReleaseVersion, _ = strconv.Atoi(identifierParts[1]) 123 | } 124 | } else { 125 | if strings.HasPrefix(identifier, "alpha") { 126 | version.PreReleaseIdentifier = "alpha" 127 | version.PreReleaseVersion, _ = strconv.Atoi(identifier[5:]) 128 | } else if strings.HasPrefix(identifier, "beta") { 129 | version.PreReleaseIdentifier = "beta" 130 | version.PreReleaseVersion, _ = strconv.Atoi(identifier[4:]) 131 | } else { 132 | version.Commit = identifier 133 | } 134 | } 135 | } 136 | versionElements := strings.Split(versionName, ".") 137 | versionLen := len(versionElements) 138 | if versionLen >= 1 { 139 | version.Major, _ = strconv.Atoi(versionElements[0]) 140 | } 141 | if versionLen >= 2 { 142 | version.Minor, _ = strconv.Atoi(versionElements[1]) 143 | } 144 | if versionLen >= 3 { 145 | version.Patch, _ = strconv.Atoi(versionElements[2]) 146 | } 147 | return 148 | } 149 | -------------------------------------------------------------------------------- /constant/rule_set.go: -------------------------------------------------------------------------------- 1 | package constant 2 | 3 | const ( 4 | RuleSetTypeDefault = "default" 5 | RuleSetTypeGitHub = "github" 6 | ) 7 | -------------------------------------------------------------------------------- /constant/version.go: -------------------------------------------------------------------------------- 1 | package constant 2 | 3 | import ( 4 | "runtime/debug" 5 | "sync" 6 | ) 7 | 8 | var ( 9 | Version = "" 10 | coreVersion string 11 | initializeCoreVersionOnce sync.Once 12 | ) 13 | 14 | func CoreVersion() string { 15 | initializeCoreVersionOnce.Do(initializeCoreVersion) 16 | return coreVersion 17 | } 18 | 19 | func initializeCoreVersion() { 20 | if !initializeCoreVersion0() { 21 | coreVersion = "unknown" 22 | } 23 | } 24 | 25 | func initializeCoreVersion0() bool { 26 | buildInfo, loaded := debug.ReadBuildInfo() 27 | if !loaded { 28 | return false 29 | } 30 | for _, it := range buildInfo.Deps { 31 | if it.Path == "github.com/sagernet/sing-box" { 32 | coreVersion = it.Version 33 | return true 34 | } 35 | } 36 | return false 37 | } 38 | -------------------------------------------------------------------------------- /docs/CNAME: -------------------------------------------------------------------------------- 1 | serenity.sagernet.org -------------------------------------------------------------------------------- /docs/assets/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /docs/changelog.md: -------------------------------------------------------------------------------- 1 | --- 2 | icon: material/alert-decagram 3 | --- 4 | 5 | #### 1.1.0-beta.3 6 | 7 | * Rename `template.servers` to `template.dns_servers` 8 | * Set `tun.route_exclude_address_set` for traffic bypassing 9 | * Fixes and improvements 10 | 11 | #### 1.1.0-alpha.3 12 | 13 | * If `extra_groups.include/exclude` is not set, 14 | subscription groups instead of subscription outbounds will be used as group outbounds 15 | * Add `template.custom_fakeip` 16 | * Fixes and improvements 17 | 18 | #### 1.1.0-alpha.1 19 | 20 | * Add support for rule actions 21 | 22 | #### 1.0.0-beta.19 23 | 24 | * Fixes and improvements 25 | 26 | #### 1.0.0-beta.17 27 | 28 | * Replace `template.extra_groups.` with `target` 29 | 30 | #### 1.0.0-beta.16 31 | 32 | * Add `export ` command to export configuration without running the server 33 | * Add `template.extra_groups.exclude_outbounds` 34 | * Add `template.extra_groups.` 35 | 36 | #### 1.0.0-beta.15 37 | 38 | * Add support for inline rule-sets **1** 39 | 40 | **1**: 41 | 42 | Will be merged into route and DNS rules in older versions. 43 | 44 | #### 1.0.0-beta.14 45 | 46 | * Rename `template.dns_default` to `template.dns` 47 | * Add `template.domain_strategy_local` 48 | 49 | #### 1.0.0-beta.13 50 | 51 | * Add `template.auto_redirect` 52 | 53 | #### 1.0.0-beta.12 54 | 55 | * Fixes and improvements 56 | 57 | #### 1.0.0-beta.11 58 | 59 | * Add `template.extend` 60 | * Add independent rule-set configuration **1** 61 | 62 | **1**: 63 | 64 | With the new `type=github`, you can batch generate rule-sets based on GitHub files. 65 | See [Rule-Set](/configuration/shared/rule-set/). 66 | 67 | #### 1.0.0-beta.10 68 | 69 | * Add `template.inbounds` 70 | 71 | #### 1.0.0-beta.9 72 | 73 | * Add `template.log` 74 | * Fixes and improvements 75 | 76 | #### 1.0.0-beta.8 77 | 78 | * Add `subscription.process.rewrite_multiplex` 79 | * Rename `subscription.process.[filter/exclude]_outbound_type` to `subscription.process.[filter/exclude]_type` 80 | * Rewrite `subscription.process` 81 | * Fixes and improvements 82 | 83 | ##### 2023/12/12 84 | 85 | No changelog before. 86 | -------------------------------------------------------------------------------- /docs/configuration/index.md: -------------------------------------------------------------------------------- 1 | # Introduction 2 | 3 | serenity uses JSON for configuration files. 4 | 5 | ### Structure 6 | 7 | ```json 8 | { 9 | "log": {}, 10 | "listen": "", 11 | "tls": {}, 12 | "cache_file": "", 13 | "outbounds": [], 14 | "subscriptions": [], 15 | "templates": [], 16 | "profiles": [], 17 | "users": [] 18 | } 19 | ``` 20 | 21 | ### Fields 22 | 23 | #### log 24 | 25 | Log configuration, see [Log](https://sing-box.sagernet.org/configuration/log/). 26 | 27 | #### listen 28 | 29 | ==Required== 30 | 31 | Listen address. 32 | 33 | #### tls 34 | 35 | TLS configuration, see [TLS](https://sing-box.sagernet.org/configuration/shared/tls/#inbound). 36 | 37 | #### cache_file 38 | 39 | Cache file path. 40 | 41 | `cache.db` will be used if empty. 42 | 43 | #### outbounds 44 | 45 | List of [Outbound][outbound], can be referenced in [Profile](./profile). 46 | 47 | For chained outbounds, use an array of outbounds as an item, and the first outbound will be the entry. 48 | 49 | #### subscriptions 50 | 51 | List of [Subscription](./subscription), can be referenced in [Profile](./profile). 52 | 53 | #### templates 54 | 55 | ==Required== 56 | 57 | List of [Template](./template), can be referenced in [Profile](./profile). 58 | 59 | #### profiles 60 | 61 | ==Required== 62 | 63 | List of [Profile](./profile), can be referenced in [User](./user). 64 | 65 | #### users 66 | 67 | ==Required== 68 | 69 | List of [User](./user). 70 | 71 | ### Check 72 | 73 | ```bash 74 | serenity check 75 | ``` 76 | 77 | ### Format 78 | 79 | ```bash 80 | serenity format -w -c config.json -D config_directory 81 | ``` 82 | 83 | [outbound]: https://sing-box.sagernet.org/configuration/outbound/ -------------------------------------------------------------------------------- /docs/configuration/profile.md: -------------------------------------------------------------------------------- 1 | ### Structure 2 | 3 | ```json 4 | { 5 | "name": "", 6 | "template": "", 7 | "template_for_platform": {}, 8 | "template_for_user_agent": {}, 9 | "outbound": [], 10 | "subscription": [] 11 | } 12 | ``` 13 | 14 | ### Fields 15 | 16 | #### name 17 | 18 | ==Required== 19 | 20 | Profile name. 21 | 22 | #### template 23 | 24 | Default template name. 25 | 26 | A empty template is used by default. 27 | 28 | #### template_for_platform 29 | 30 | Custom template for different graphical client. 31 | 32 | The key is one of `android`, `ios`, `macos`, `tvos`. 33 | 34 | The Value is the template name. 35 | 36 | #### template_for_user_agent 37 | 38 | Custom template for different user agent. 39 | 40 | The key is a regular expression matching the user agent. 41 | 42 | The value is the template name. 43 | 44 | #### outbound 45 | 46 | Included outbounds. 47 | 48 | #### subscription 49 | 50 | Included subscriptions. 51 | -------------------------------------------------------------------------------- /docs/configuration/shared/rule-set.md: -------------------------------------------------------------------------------- 1 | # RuleSet 2 | 3 | RuleSet generate configuration. 4 | 5 | ### Structure 6 | 7 | === "Original" 8 | 9 | ```json 10 | { 11 | "type": "remote", // or local 12 | 13 | ... // Original Fields 14 | } 15 | ``` 16 | 17 | === "GitHub" 18 | 19 | ```json 20 | { 21 | "type": "github", 22 | "repository": "", 23 | "path": "", 24 | "rule_set": [] 25 | } 26 | ``` 27 | 28 | === "Example" 29 | 30 | ```json 31 | { 32 | "type": "github", 33 | "repository": "SagerNet/sing-geosite", 34 | "path": "rule-set/geosite-", 35 | "prefix": "geosite-", 36 | "rule_set": [ 37 | "apple", 38 | "microsoft", 39 | "openai" 40 | ] 41 | } 42 | ``` 43 | 44 | === "Example (Clash.Meta repository)" 45 | 46 | ```json 47 | { 48 | "type": "github", 49 | "repository": "MetaCubeX/meta-rules-dat", 50 | "path": "sing/geo/geosite/", 51 | "prefix": "geosite-", 52 | "rule_set": [ 53 | "apple", 54 | "microsoft", 55 | "openai" 56 | ] 57 | } 58 | ``` 59 | 60 | ### Original Fields 61 | 62 | See [RuleSet](https://sing-box.sagernet.org/configuration/rule-set/). 63 | 64 | ### GitHub Fields 65 | 66 | #### repository 67 | 68 | GitHub repository, `SagerNet/sing-` or `MetaCubeX/meta-rules-dat`. 69 | 70 | #### path 71 | 72 | Branch and directory path, `rule-set` or `sing/geo/`. 73 | 74 | #### prefix 75 | 76 | File prefix, `geoip-` or `geosite-`. 77 | 78 | #### rule_set 79 | 80 | RuleSet name list. 81 | -------------------------------------------------------------------------------- /docs/configuration/subscription.md: -------------------------------------------------------------------------------- 1 | ### Structure 2 | 3 | ```json 4 | { 5 | "name": "", 6 | "url": "", 7 | "user_agent": "", 8 | "process": [ 9 | { 10 | "filter": [], 11 | "exclude": [], 12 | "filter_type": [], 13 | "exclude_type": [], 14 | "invert": false, 15 | "remove": false, 16 | "rename": {}, 17 | "remove_emoji": false, 18 | "rewrite_multiplex": {} 19 | } 20 | ], 21 | "deduplication": false, 22 | "update_interval": "5m", 23 | "generate_selector": false, 24 | "generate_urltest": false, 25 | "urltest_suffix": "", 26 | "custom_selector": {}, 27 | "custom_urltest": {} 28 | } 29 | ``` 30 | 31 | ### Fields 32 | 33 | #### name 34 | 35 | ==Required== 36 | 37 | Name of the subscription, will be used in group tags. 38 | 39 | #### url 40 | 41 | ==Required== 42 | 43 | Subscription URL. 44 | 45 | #### user_agent 46 | 47 | User-Agent in HTTP request. 48 | 49 | `serenity/$version (sing-box $sing-box-version; Clash compatible)` is used by default. 50 | 51 | #### process 52 | 53 | !!! note "" 54 | 55 | You can ignore the JSON Array [] tag when the content is only one item 56 | 57 | Process rules. 58 | 59 | #### process.filter 60 | 61 | Regexp filter rules, match outbound tag name. 62 | 63 | #### process.exclude 64 | 65 | Regexp exclude rules, match outbound tag name. 66 | 67 | #### process.filter_type 68 | 69 | Filter rules, match outbound type. 70 | 71 | #### process.exclude_type 72 | 73 | Exclude rules, match outbound type. 74 | 75 | #### process.invert 76 | 77 | Invert filter results. 78 | 79 | #### process.remove 80 | 81 | Remove outbounds that match the rules. 82 | 83 | #### process.rename 84 | 85 | Regexp rename rules, matching outbounds will be renamed. 86 | 87 | #### process.remove_emoji 88 | 89 | Remove emojis in outbound tags. 90 | 91 | #### process.rewrite_multiplex 92 | 93 | Rewrite [Multiplex](https://sing-box.sagernet.org/configuration/shared/multiplex) options. 94 | 95 | #### deduplication 96 | 97 | Remove outbounds with duplicate server destinations (Domain will be resolved to compare). 98 | 99 | #### update_interval 100 | 101 | Subscription update interval. 102 | 103 | `1h` is used by default. 104 | 105 | #### generate_selector 106 | 107 | Generate a global `Selector` outbound for the subscription. 108 | 109 | If both `generate_selector` and `generate_urltest` are disabled, subscription outbounds will be added to global groups. 110 | 111 | #### generate_urltest 112 | 113 | Generate a global `URLTest` outbound for the subscription. 114 | 115 | If both `generate_selector` and `generate_urltest` are disabled, subscription outbounds will be added to global groups. 116 | 117 | #### urltest_suffix 118 | 119 | Tag suffix of generated `URLTest` outbound. 120 | 121 | ` - URLTest` is used by default. 122 | 123 | #### custom_selector 124 | 125 | Custom [Selector](https://sing-box.sagernet.org/configuration/outbound/selector/) template. 126 | 127 | #### custom_urltest 128 | 129 | Custom [URLTest](https://sing-box.sagernet.org/configuration/outbound/urltest/) template. 130 | -------------------------------------------------------------------------------- /docs/configuration/template.md: -------------------------------------------------------------------------------- 1 | ### Structure 2 | 3 | ```json 4 | { 5 | "name": "", 6 | "extend": "", 7 | 8 | // Global 9 | 10 | "log": {}, 11 | "domain_strategy": "", 12 | "domain_strategy_local": "", 13 | "disable_traffic_bypass": false, 14 | "disable_sniff": false, 15 | "disable_rule_action": false, 16 | "remote_resolve": false, 17 | 18 | // DNS 19 | 20 | "dns": "", 21 | "dns_local": "", 22 | "dns_servers": [], 23 | "enable_fakeip": false, 24 | "pre_dns_rules": [], 25 | "custom_dns_rules": [], 26 | "custom_fakeip": {}, 27 | 28 | // Inbound 29 | 30 | "inbounds": [], 31 | "auto_redirect": false, 32 | "disable_tun": false, 33 | "disable_system_proxy": false, 34 | "custom_tun": {}, 35 | "custom_mixed": {}, 36 | 37 | // Outbound 38 | 39 | "extra_groups": [ 40 | { 41 | "tag": "", 42 | "type": "", 43 | "target": "", 44 | "tag_per_subscription": "", 45 | "filter": "", 46 | "exclude": "", 47 | "custom_selector": {}, 48 | "custom_urltest": {} 49 | } 50 | ], 51 | "direct_tag": "", 52 | "default_tag": "", 53 | "urltest_tag": "", 54 | "custom_direct": {}, 55 | "custom_selector": {}, 56 | "custom_urltest": {}, 57 | 58 | // Route 59 | 60 | "pre_rules": [], 61 | "custom_rules": [], 62 | "enable_jsdelivr": false, 63 | "custom_geoip": {}, 64 | "custom_geosite": {}, 65 | "custom_rule_set": [], 66 | "post_rule_set": [], 67 | 68 | // Experimental 69 | 70 | "disable_cache_file": false, 71 | "disable_clash_mode": false, 72 | "clash_mode_rule": "", 73 | "clash_mode_global": "", 74 | "clash_mode_direct": "", 75 | "custom_clash_api": {}, 76 | 77 | // Debug 78 | 79 | "pprof_listen": "", 80 | "memory_limit": "" 81 | } 82 | ``` 83 | 84 | ### Fields 85 | 86 | #### name 87 | 88 | ==Required== 89 | 90 | Profile name. 91 | 92 | #### extend 93 | 94 | Extend from another profile. 95 | 96 | #### log 97 | 98 | Log configuration, see [Log](https://sing-box.sagernet.org/configuration/log/). 99 | 100 | #### domain_strategy 101 | 102 | Global sing-box domain strategy. 103 | 104 | One of `prefer_ipv4` `prefer_ipv6` `ipv4_only` `ipv6_only`. 105 | 106 | If `*_only` enabled, TUN and DNS will be configured to disable the other network. 107 | 108 | Note that if want `prefer_*` to take effect on transparent proxy requests, set `enable_fakeip`. 109 | 110 | `ipv4_only` is used by default when `enable_fakeip` disabled, 111 | `prefer_ipv4` is used by default when `enable_fakeip` enabled. 112 | 113 | #### domain_strategy_local 114 | 115 | Local sing-box domain strategy. 116 | 117 | `prefer_ipv4` is used by default. 118 | 119 | #### disable_sniff 120 | 121 | Don`t generate protocol sniffing options. 122 | 123 | #### disable_rule_action 124 | 125 | Don`t generate rule action options. 126 | 127 | #### disable_traffic_bypass 128 | 129 | Disable traffic bypass for Chinese DNS queries and connections. 130 | 131 | #### remote_resolve 132 | 133 | Don't generate `doamin_strategy` options for inbounds. 134 | 135 | #### dns 136 | 137 | Default DNS server. 138 | 139 | `tls://8.8.8.8` is used by default. 140 | 141 | #### dns_local 142 | 143 | DNS server used for China DNS requests. 144 | 145 | `114.114.114.114` is used by default. 146 | 147 | #### dns_servers 148 | 149 | List of [DNS Server](https://sing-box.sagernet.org/configuration/dns/server/). 150 | 151 | Will be append to DNS servers. 152 | 153 | #### enable_fakeip 154 | 155 | Enable FakeIP. 156 | 157 | #### pre_dns_rules 158 | 159 | List of [DNS Rule](https://sing-box.sagernet.org/configuration/dns/rule/). 160 | 161 | Will be applied before traffic bypassing rules. 162 | 163 | #### custom_dns_rules 164 | 165 | List of [DNS Rule](https://sing-box.sagernet.org/configuration/dns/rule/). 166 | 167 | No default traffic bypassing DNS rules will be generated if not empty. 168 | 169 | #### custom_fakeip 170 | 171 | Custom [FakeIP](https://sing-box.sagernet.org/configuration/dns/fakeip/) template. 172 | 173 | #### inbounds 174 | 175 | List of [Inbound](https://sing-box.sagernet.org/configuration/inbound/). 176 | 177 | #### auto_redirect 178 | 179 | Generate [auto-redirect](https://sing-box.sagernet.org/configuration/inbound/tun/#auto_redirect) options for android and unknown platforms. 180 | 181 | #### disable_tun 182 | 183 | Don't generate TUN inbound. 184 | 185 | If the target platform can only use TUN for proxy (currently all Apple platforms), this item will not take effect. 186 | 187 | #### disable_system_proxy 188 | 189 | Don't generate `tun.platform.http_proxy` for known platforms and `set_system_proxy` for unknown platforms. 190 | 191 | #### custom_tun 192 | 193 | Custom [TUN](https://sing-box.sagernet.org/configuration/inbound/tun/) inbound template. 194 | 195 | #### custom_mixed 196 | 197 | Custom [Mixed](https://sing-box.sagernet.org/configuration/inbound/mixed/) inbound template. 198 | 199 | #### extra_groups 200 | 201 | Generate extra outbound groups. 202 | 203 | #### extra_groups.tag 204 | 205 | ==Required== 206 | 207 | Tag of the group outbound. 208 | 209 | #### extra_groups.type 210 | 211 | ==Required== 212 | 213 | Type of the group outbound. 214 | 215 | #### extra_groups.target 216 | 217 | | Value | Description | 218 | |----------------|------------------------------------------------------------| 219 | | `default` | No additional behaviors. | 220 | | `global` | Generate a group and add it to default selector. | 221 | | `subscription` | Generate a internal group for every subscription selector. | 222 | 223 | #### extra_groups.tag_per_subscription 224 | 225 | Tag for every new subscription internal group when `target` is `subscription`. 226 | 227 | `{{ .tag }} ({{ .subscription_name }})` is used by default. 228 | 229 | #### extra_groups.filter 230 | 231 | Regexp filter rules, non-matching outbounds will be removed. 232 | 233 | #### extra_groups.exclude 234 | 235 | Regexp exclude rules, matching outbounds will be removed. 236 | 237 | #### extra_groups.custom_selector 238 | 239 | Custom [Selector](https://sing-box.sagernet.org/configuration/outbound/selector/) template. 240 | 241 | #### extra_groups.custom_urltest 242 | 243 | Custom [URLTest](https://sing-box.sagernet.org/configuration/outbound/urltest/) template. 244 | 245 | #### direct_tag 246 | 247 | Custom direct outbound tag. 248 | 249 | #### default_tag 250 | 251 | Custom default outbound tag. 252 | 253 | #### urltest_tag 254 | 255 | Custom URLTest outbound tag. 256 | 257 | #### custom_direct 258 | 259 | Custom [Direct](https://sing-box.sagernet.org/configuration/outbound/direct/) outbound template. 260 | 261 | #### custom_selector 262 | 263 | Custom [Selector](https://sing-box.sagernet.org/configuration/outbound/selector/) outbound template. 264 | 265 | #### custom_urltest 266 | 267 | Custom [URLTest](https://sing-box.sagernet.org/configuration/outbound/urltest/) outbound template. 268 | 269 | #### pre_rules 270 | 271 | List of [Rule](https://sing-box.sagernet.org/configuration/route/rule/). 272 | 273 | Will be applied before traffic bypassing rules. 274 | 275 | #### custom_rules 276 | 277 | List of [Rule](https://sing-box.sagernet.org/configuration/route/rule/). 278 | 279 | No default traffic bypassing rules will be generated if not empty. 280 | 281 | #### enable_jsdelivr 282 | 283 | Use jsDelivr CDN and direct outbound for default rule sets or Geo resources. 284 | 285 | #### custom_geoip 286 | 287 | Custom [GeoIP](https://sing-box.sagernet.org/configuration/route/geoip/) template. 288 | 289 | #### custom_geosite 290 | 291 | Custom [GeoSite](https://sing-box.sagernet.org/configuration/route/geosite/) template. 292 | 293 | #### custom_rule_set 294 | 295 | List of [RuleSet](/configuration/shared/rule-set/). 296 | 297 | Default rule sets will not be generated if not empty. 298 | 299 | #### post_rule_set 300 | 301 | List of [RuleSet](/configuration/shared/rule-set/). 302 | 303 | Will be applied after default rule sets. 304 | 305 | #### disable_cache_file 306 | 307 | Don't generate `cache_file` related options. 308 | 309 | #### disable_clash_mode 310 | 311 | Don't generate `clash_mode` related options. 312 | 313 | #### clash_mode_rule 314 | 315 | Name of the 'Rule' Clash mode. 316 | 317 | `Rule` is used by default. 318 | 319 | #### clash_mode_global 320 | 321 | Name of the 'Global' Clash mode. 322 | 323 | `Global` is used by default. 324 | 325 | #### clash_mode_direct 326 | 327 | Name of the 'Direct' Clash mode. 328 | 329 | `Direct` is used by default. 330 | 331 | #### custom_clash_api 332 | 333 | Custom [Clash API](https://sing-box.sagernet.org/configuration/experimental/clash-api/) template. 334 | 335 | #### pprof_listen 336 | 337 | Listen address of the pprof server. 338 | 339 | #### memory_limit 340 | 341 | Set soft memory limit for sing-box. 342 | 343 | `100m` is recommended if memory limit is required. 344 | -------------------------------------------------------------------------------- /docs/configuration/user.md: -------------------------------------------------------------------------------- 1 | ### Structure 2 | 3 | ```json 4 | { 5 | "name": "", 6 | "password": "", 7 | "profile": [], 8 | "default_profile": "" 9 | } 10 | ``` 11 | 12 | !!! note "" 13 | 14 | You can ignore the JSON Array [] tag when the content is only one item 15 | 16 | ### Fields 17 | 18 | #### name 19 | 20 | HTTP basic authentication username. 21 | 22 | #### password 23 | 24 | HTTP basic authentication password. 25 | 26 | #### profile 27 | 28 | Accessible profiles for this user. 29 | 30 | List of [Profile](./profile) name. 31 | 32 | #### default_profile 33 | 34 | Default profile name. 35 | 36 | First profile is used by default. 37 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | description: Welcome to the wiki page for the serenity project. 3 | --- 4 | 5 | # :material-home: Home 6 | 7 | Welcome to the wiki page for the serenity project. 8 | 9 | The configuration generator for sing-box. 10 | 11 | ## License 12 | 13 | ``` 14 | Copyright (C) 2022 by nekohasekai 15 | 16 | This program is free software: you can redistribute it and/or modify 17 | it under the terms of the GNU General Public License as published by 18 | the Free Software Foundation, either version 3 of the License, or 19 | (at your option) any later version. 20 | 21 | This program is distributed in the hope that it will be useful, 22 | but WITHOUT ANY WARRANTY; without even the implied warranty of 23 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 24 | GNU General Public License for more details. 25 | 26 | You should have received a copy of the GNU General Public License 27 | along with this program. If not, see . 28 | 29 | In addition, no derivative work may use the name or imply association 30 | with this application without prior consent. 31 | ``` 32 | -------------------------------------------------------------------------------- /docs/index.zh.md: -------------------------------------------------------------------------------- 1 | --- 2 | description: 欢迎来到该 serenity 项目的文档页。 3 | --- 4 | 5 | # :material-home: 开始 6 | 7 | 欢迎来到该 serenity 项目的文档页。 8 | 9 | sing-box 配置生成器。 10 | 11 | ## 授权 12 | 13 | ``` 14 | Copyright (C) 2022 by nekohasekai 15 | 16 | This program is free software: you can redistribute it and/or modify 17 | it under the terms of the GNU General Public License as published by 18 | the Free Software Foundation, either version 3 of the License, or 19 | (at your option) any later version. 20 | 21 | This program is distributed in the hope that it will be useful, 22 | but WITHOUT ANY WARRANTY; without even the implied warranty of 23 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 24 | GNU General Public License for more details. 25 | 26 | You should have received a copy of the GNU General Public License 27 | along with this program. If not, see . 28 | 29 | In addition, no derivative work may use the name or imply association 30 | with this application without prior consent. 31 | ``` 32 | -------------------------------------------------------------------------------- /docs/installation/build-from-source.md: -------------------------------------------------------------------------------- 1 | --- 2 | icon: material/file-code 3 | --- 4 | 5 | # Build from source 6 | 7 | ## :material-graph: Requirements 8 | 9 | * Go 1.23.x 10 | 11 | You can download and install Go from: https://go.dev/doc/install, latest version is recommended. 12 | 13 | ## :material-fast-forward: Build 14 | 15 | ```bash 16 | make 17 | ``` 18 | 19 | Or build and install binary to `$GOBIN`: 20 | 21 | ```bash 22 | make install 23 | ``` 24 | -------------------------------------------------------------------------------- /docs/installation/build-from-source.zh.md: -------------------------------------------------------------------------------- 1 | --- 2 | icon: material/file-code 3 | --- 4 | 5 | # 从源代码构建 6 | 7 | ## :material-graph: 要求 8 | 9 | * Go 1.23.x 10 | 11 | 您可以从 https://go.dev/doc/install 下载并安装 Go,推荐使用最新版本。 12 | 13 | ## :material-fast-forward: 构建 14 | 15 | ```bash 16 | make 17 | ``` 18 | 19 | 或者构建二进制文件并将其安装到 `$GOBIN`: 20 | 21 | ```bash 22 | make install 23 | ``` -------------------------------------------------------------------------------- /docs/installation/docker.md: -------------------------------------------------------------------------------- 1 | --- 2 | icon: material/docker 3 | --- 4 | 5 | # Docker 6 | 7 | ## :material-console: Command 8 | 9 | ```bash 10 | docker run -d \ 11 | -v /etc/serenity:/etc/serenity/ \ 12 | --name=serenity \ 13 | --restart=always \ 14 | ghcr.io/sagernet/serenity \ 15 | -D /var/lib/serenity \ 16 | -C /etc/serenity/ run 17 | ``` 18 | 19 | ## :material-box-shadow: Compose 20 | 21 | ```yaml 22 | version: "3.8" 23 | services: 24 | serenity: 25 | image: ghcr.io/sagernet/serenity 26 | container_name: serenity 27 | restart: always 28 | volumes: 29 | - /etc/serenity:/etc/serenity/ 30 | command: -D /var/lib/serenity -C /etc/serenity/ run 31 | ``` 32 | -------------------------------------------------------------------------------- /docs/installation/docker.zh.md: -------------------------------------------------------------------------------- 1 | --- 2 | icon: material/docker 3 | --- 4 | 5 | # Docker 6 | 7 | ## :material-console: 命令 8 | 9 | ```bash 10 | docker run -d \ 11 | -v /etc/serenity:/etc/serenity/ \ 12 | --name=serenity \ 13 | --restart=always \ 14 | ghcr.io/sagernet/serenity \ 15 | -D /var/lib/serenity \ 16 | -C /etc/serenity/ run 17 | ``` 18 | 19 | ## :material-box-shadow: Compose 20 | 21 | ```yaml 22 | version: "3.8" 23 | services: 24 | serenity: 25 | image: ghcr.io/sagernet/serenity 26 | container_name: serenity 27 | restart: always 28 | volumes: 29 | - /etc/serenity:/etc/serenity/ 30 | command: -D /var/lib/serenity -C /etc/serenity/ run 31 | ``` 32 | -------------------------------------------------------------------------------- /docs/installation/package-manager.md: -------------------------------------------------------------------------------- 1 | --- 2 | icon: material/package 3 | --- 4 | 5 | # Package Manager 6 | 7 | ## :material-tram: Repository Installation 8 | 9 | === ":material-debian: Debian / APT" 10 | 11 | ```bash 12 | sudo curl -fsSL https://deb.sagernet.org/gpg.key -o /etc/apt/keyrings/sagernet.asc 13 | sudo chmod a+r /etc/apt/keyrings/sagernet.asc 14 | echo "deb [arch=`dpkg --print-architecture` signed-by=/etc/apt/keyrings/sagernet.asc] https://deb.sagernet.org/ * *" | \ 15 | sudo tee /etc/apt/sources.list.d/sagernet.list > /dev/null 16 | sudo apt-get update 17 | sudo apt-get install serenity 18 | ``` 19 | 20 | === ":material-redhat: Redhat / DNF" 21 | 22 | ```bash 23 | sudo dnf -y install dnf-plugins-core 24 | sudo dnf config-manager --add-repo https://sing-box.app/rpm.repo 25 | sudo dnf install serenity 26 | ``` 27 | 28 | === ":material-redhat: CentOS / YUM" 29 | 30 | ```bash 31 | sudo yum install -y yum-utils 32 | sudo yum-config-manager --add-repo https://sing-box.app/rpm.repo 33 | sudo yum install serenity 34 | ``` 35 | 36 | ## :material-download-box: Manual Installation 37 | 38 | === ":material-debian: Debian / DEB" 39 | 40 | ```bash 41 | bash <(curl -fsSL https://serenity.app/serenity/deb-install.sh) 42 | ``` 43 | 44 | === ":material-redhat: Redhat / RPM" 45 | 46 | ```bash 47 | bash <(curl -fsSL https://sing-box.app/serenity/rpm-install.sh) 48 | ``` 49 | 50 | === ":simple-archlinux: Archlinux / PKG" 51 | 52 | ```bash 53 | bash <(curl -fsSL https://sing-box.app/serenity/arch-install.sh) 54 | ``` 55 | 56 | ## :material-book-multiple: Service Management 57 | 58 | For Linux systems with [systemd][systemd], usually the installation already includes a serenity service, 59 | you can manage the service using the following command: 60 | 61 | | Operation | Command | 62 | |-----------|-----------------------------------------------| 63 | | Enable | `sudo systemctl enable serenity` | 64 | | Disable | `sudo systemctl disable serenity` | 65 | | Start | `sudo systemctl start serenity` | 66 | | Stop | `sudo systemctl stop serenity` | 67 | | Kill | `sudo systemctl kill serenity` | 68 | | Restart | `sudo systemctl restart serenity` | 69 | | Logs | `sudo journalctl -u serenity --output cat -e` | 70 | | New Logs | `sudo journalctl -u serenity --output cat -f` | 71 | 72 | [systemd]: https://systemd.io/ -------------------------------------------------------------------------------- /docs/installation/package-manager.zh.md: -------------------------------------------------------------------------------- 1 | --- 2 | icon: material/package 3 | --- 4 | 5 | # 包管理器 6 | 7 | ## :material-tram: 仓库安装 8 | 9 | === ":material-debian: Debian / APT" 10 | 11 | ```bash 12 | sudo curl -fsSL https://deb.sagernet.org/gpg.key -o /etc/apt/keyrings/sagernet.asc 13 | sudo chmod a+r /etc/apt/keyrings/sagernet.asc 14 | echo "deb [arch=`dpkg --print-architecture` signed-by=/etc/apt/keyrings/sagernet.asc] https://deb.sagernet.org/ * *" | \ 15 | sudo tee /etc/apt/sources.list.d/sagernet.list > /dev/null 16 | sudo apt-get update 17 | sudo apt-get install serenity 18 | ``` 19 | 20 | === ":material-redhat: Redhat / DNF" 21 | 22 | ```bash 23 | sudo dnf -y install dnf-plugins-core 24 | sudo dnf config-manager --add-repo https://sing-box.app/rpm.repo 25 | sudo dnf install serenity 26 | ``` 27 | 28 | === ":material-redhat: CentOS / YUM" 29 | 30 | ```bash 31 | sudo yum install -y yum-utils 32 | sudo yum-config-manager --add-repo https://sing-box.app/rpm.repo 33 | sudo yum install serenity 34 | ``` 35 | 36 | ## :material-download-box: 手动安装 37 | 38 | === ":material-debian: Debian / DEB" 39 | 40 | ```bash 41 | bash <(curl -fsSL https://sing-box.app/serenity/deb-install.sh) 42 | ``` 43 | 44 | === ":material-redhat: Redhat / RPM" 45 | 46 | ```bash 47 | bash <(curl -fsSL https://sing-box.app/serenity/rpm-install.sh) 48 | ``` 49 | 50 | === ":simple-archlinux: Archlinux / PKG" 51 | 52 | ```bash 53 | bash <(curl -fsSL https://sing-box.app/serenity/arch-install.sh) 54 | ``` 55 | 56 | ## :material-book-multiple: 服务管理 57 | 58 | 对于带有 [systemd][systemd] 的 Linux 系统,通常安装已经包含 serenity 服务, 59 | 您可以使用以下命令管理服务: 60 | 61 | | 行动 | 命令 | 62 | |------|-----------------------------------------------| 63 | | 启用 | `sudo systemctl enable serenity` | 64 | | 禁用 | `sudo systemctl disable serenity` | 65 | | 启动 | `sudo systemctl start serenity` | 66 | | 停止 | `sudo systemctl stop serenity` | 67 | | 强行停止 | `sudo systemctl kill serenity` | 68 | | 重新启动 | `sudo systemctl restart serenity` | 69 | | 查看日志 | `sudo journalctl -u serenity --output cat -e` | 70 | | 实时日志 | `sudo journalctl -u serenity --output cat -f` | 71 | 72 | [systemd]: https://systemd.io/ -------------------------------------------------------------------------------- /docs/installation/tools/arch-install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e -o pipefail 4 | 5 | ARCH_RAW=$(uname -m) 6 | case "${ARCH_RAW}" in 7 | 'x86_64') ARCH='amd64';; 8 | 'x86' | 'i686' | 'i386') ARCH='386';; 9 | 'aarch64' | 'arm64') ARCH='arm64';; 10 | 'armv7l') ARCH='armv7';; 11 | 's390x') ARCH='s390x';; 12 | *) echo "Unsupported architecture: ${ARCH_RAW}"; exit 1;; 13 | esac 14 | 15 | VERSION=$(curl -s https://api.github.com/repos/SagerNet/serenity/releases/latest \ 16 | | grep tag_name \ 17 | | cut -d ":" -f2 \ 18 | | sed 's/\"//g;s/\,//g;s/\ //g;s/v//') 19 | 20 | curl -Lo serenity.pkg.tar.zst "https://github.com/SagerNet/serenity/releases/download/v${VERSION}/serenity_${VERSION}_linux_${ARCH}.pkg.tar.zst" 21 | sudo pacman -U serenity.pkg.tar.zst 22 | rm serenity.pkg.tar.zst 23 | -------------------------------------------------------------------------------- /docs/installation/tools/deb-install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e -o pipefail 4 | 5 | ARCH_RAW=$(uname -m) 6 | case "${ARCH_RAW}" in 7 | 'x86_64') ARCH='amd64';; 8 | 'x86' | 'i686' | 'i386') ARCH='386';; 9 | 'aarch64' | 'arm64') ARCH='arm64';; 10 | 'armv7l') ARCH='armv7';; 11 | 's390x') ARCH='s390x';; 12 | *) echo "Unsupported architecture: ${ARCH_RAW}"; exit 1;; 13 | esac 14 | 15 | VERSION=$(curl -s https://api.github.com/repos/SagerNet/serentry/releases/latest \ 16 | | grep tag_name \ 17 | | cut -d ":" -f2 \ 18 | | sed 's/\"//g;s/\,//g;s/\ //g;s/v//') 19 | 20 | curl -Lo serentry.deb "https://github.com/SagerNet/serentry/releases/download/v${VERSION}/serentry_${VERSION}_linux_${ARCH}.deb" 21 | sudo dpkg -i serentry.deb 22 | rm serentry.deb 23 | 24 | -------------------------------------------------------------------------------- /docs/installation/tools/rpm-install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e -o pipefail 4 | 5 | ARCH_RAW=$(uname -m) 6 | case "${ARCH_RAW}" in 7 | 'x86_64') ARCH='amd64';; 8 | 'x86' | 'i686' | 'i386') ARCH='386';; 9 | 'aarch64' | 'arm64') ARCH='arm64';; 10 | 'armv7l') ARCH='armv7';; 11 | 's390x') ARCH='s390x';; 12 | *) echo "Unsupported architecture: ${ARCH_RAW}"; exit 1;; 13 | esac 14 | 15 | VERSION=$(curl -s https://api.github.com/repos/SagerNet/serentry/releases/latest \ 16 | | grep tag_name \ 17 | | cut -d ":" -f2 \ 18 | | sed 's/\"//g;s/\,//g;s/\ //g;s/v//') 19 | 20 | curl -Lo serentry.rpm "https://github.com/SagerNet/serentry/releases/download/v${VERSION}/serentry_${VERSION}_linux_${ARCH}.rpm" 21 | sudo rpm -i serentry.rpm 22 | rm serentry.rpm 23 | -------------------------------------------------------------------------------- /docs/installation/tools/rpm.repo: -------------------------------------------------------------------------------- 1 | [serentry] 2 | name=serentry 3 | baseurl=https://rpm.sagernet.org/ 4 | enabled=1 5 | gpgcheck=1 6 | gpgkey=https://deb.sagernet.org/gpg.key 7 | -------------------------------------------------------------------------------- /docs/support.md: -------------------------------------------------------------------------------- 1 | --- 2 | icon: material/forum 3 | --- 4 | 5 | # Support 6 | 7 | | Channel | Link | 8 | |:------------------------------|:--------------------------------------------| 9 | | Community | https://community.sagernet.org | 10 | | GitHub Issues | https://github.com/SagerNet/serenity/issues | 11 | | Telegram notification channel | https://t.me/yapnc | 12 | | Telegram user group | https://t.me/yapug | 13 | | Email | contact@sagernet.org | 14 | -------------------------------------------------------------------------------- /docs/support.zh.md: -------------------------------------------------------------------------------- 1 | --- 2 | icon: material/forum 3 | --- 4 | 5 | # 支持 6 | 7 | | 通道 | 链接 | 8 | |:--------------|:--------------------------------------------| 9 | | 社区 | https://community.sagernet.org | 10 | | GitHub Issues | https://github.com/SagerNet/serenity/issues | 11 | | Telegram 通知频道 | https://t.me/yapnc | 12 | | Telegram 用户组 | https://t.me/yapug | 13 | | 邮件 | contact@sagernet.org | 14 | 15 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/sagernet/serenity 2 | 3 | go 1.23.1 4 | 5 | require ( 6 | github.com/Dreamacro/clash v1.18.0 7 | github.com/go-chi/chi/v5 v5.2.1 8 | github.com/go-chi/cors v1.2.1 9 | github.com/go-chi/render v1.0.3 10 | github.com/miekg/dns v1.1.66 11 | github.com/sagernet/bbolt v0.0.0-20231014093535-ea5cb2fe9f0a 12 | github.com/sagernet/sing v0.6.11-0.20250521033217-30d675ea099b 13 | github.com/sagernet/sing-box v1.12.0-beta.19 14 | github.com/sagernet/sing-dns v0.4.0 15 | github.com/spf13/cobra v1.9.1 16 | github.com/stretchr/testify v1.10.0 17 | golang.org/x/mod v0.24.0 18 | golang.org/x/net v0.40.0 19 | ) 20 | 21 | require ( 22 | filippo.io/edwards25519 v1.1.0 // indirect 23 | github.com/Dreamacro/protobytes v0.0.0-20230617041236-6500a9f4f158 // indirect 24 | github.com/ajg/form v1.5.1 // indirect 25 | github.com/akutz/memconn v0.1.0 // indirect 26 | github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa // indirect 27 | github.com/andybalholm/brotli v1.1.0 // indirect 28 | github.com/anytls/sing-anytls v0.0.8 // indirect 29 | github.com/bits-and-blooms/bitset v1.13.0 // indirect 30 | github.com/caddyserver/certmagic v0.23.0 // indirect 31 | github.com/caddyserver/zerossl v0.1.3 // indirect 32 | github.com/cloudflare/circl v1.6.1 // indirect 33 | github.com/coder/websocket v1.8.12 // indirect 34 | github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6 // indirect 35 | github.com/cretz/bine v0.2.0 // indirect 36 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 37 | github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa // indirect 38 | github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e // indirect 39 | github.com/dlclark/regexp2 v1.10.0 // indirect 40 | github.com/fsnotify/fsnotify v1.7.0 // indirect 41 | github.com/fxamacker/cbor/v2 v2.7.0 // indirect 42 | github.com/gaissmai/bart v0.11.1 // indirect 43 | github.com/go-json-experiment/json v0.0.0-20250103232110-6a9a0fde9288 // indirect 44 | github.com/go-ole/go-ole v1.3.0 // indirect 45 | github.com/gobwas/httphead v0.1.0 // indirect 46 | github.com/gobwas/pool v0.2.1 // indirect 47 | github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466 // indirect 48 | github.com/gofrs/uuid/v5 v5.3.2 // indirect 49 | github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect 50 | github.com/google/btree v1.1.3 // indirect 51 | github.com/google/go-cmp v0.6.0 // indirect 52 | github.com/google/nftables v0.2.1-0.20240414091927-5e242ec57806 // indirect 53 | github.com/google/uuid v1.6.0 // indirect 54 | github.com/gorilla/csrf v1.7.3-0.20250123201450-9dd6af1f6d30 // indirect 55 | github.com/gorilla/securecookie v1.1.2 // indirect 56 | github.com/gorilla/websocket v1.5.0 // indirect 57 | github.com/hashicorp/yamux v0.1.2 // indirect 58 | github.com/hdevalence/ed25519consensus v0.2.0 // indirect 59 | github.com/illarion/gonotify/v2 v2.0.3 // indirect 60 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 61 | github.com/insomniacslk/dhcp v0.0.0-20250417080101-5f8cf70e8c5f // indirect 62 | github.com/jsimonetti/rtnetlink v1.4.0 // indirect 63 | github.com/klauspost/compress v1.17.11 // indirect 64 | github.com/klauspost/cpuid/v2 v2.2.10 // indirect 65 | github.com/kortschak/wol v0.0.0-20200729010619-da482cc4850a // indirect 66 | github.com/libdns/alidns v1.0.4-libdns.v1.beta1 // indirect 67 | github.com/libdns/cloudflare v0.2.2-0.20250430151523-b46a2b0885f6 // indirect 68 | github.com/libdns/libdns v1.0.0-beta.1 // indirect 69 | github.com/logrusorgru/aurora v2.0.3+incompatible // indirect 70 | github.com/mdlayher/genetlink v1.3.2 // indirect 71 | github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 // indirect 72 | github.com/mdlayher/sdnotify v1.0.0 // indirect 73 | github.com/mdlayher/socket v0.5.1 // indirect 74 | github.com/metacubex/tfo-go v0.0.0-20241231083714-66613d49c422 // indirect 75 | github.com/metacubex/utls v1.7.0-alpha.3 // indirect 76 | github.com/mholt/acmez/v3 v3.1.2 // indirect 77 | github.com/mitchellh/go-ps v1.0.0 // indirect 78 | github.com/oschwald/geoip2-golang v1.9.0 // indirect 79 | github.com/oschwald/maxminddb-golang v1.13.1 // indirect 80 | github.com/pierrec/lz4/v4 v4.1.21 // indirect 81 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect 82 | github.com/prometheus-community/pro-bing v0.4.0 // indirect 83 | github.com/quic-go/qpack v0.5.1 // indirect 84 | github.com/safchain/ethtool v0.3.0 // indirect 85 | github.com/sagernet/cors v1.2.1 // indirect 86 | github.com/sagernet/fswatch v0.1.1 // indirect 87 | github.com/sagernet/gvisor v0.0.0-20250325023245-7a9c0f5725fb // indirect 88 | github.com/sagernet/netlink v0.0.0-20240612041022-b9a21c07ac6a // indirect 89 | github.com/sagernet/nftables v0.3.0-beta.4 // indirect 90 | github.com/sagernet/quic-go v0.52.0-beta.1 // indirect 91 | github.com/sagernet/sing-mux v0.3.2 // indirect 92 | github.com/sagernet/sing-quic v0.5.0-beta.1 // indirect 93 | github.com/sagernet/sing-shadowsocks v0.2.7 // indirect 94 | github.com/sagernet/sing-shadowsocks2 v0.2.1 // indirect 95 | github.com/sagernet/sing-shadowtls v0.2.1-0.20250503051639-fcd445d33c11 // indirect 96 | github.com/sagernet/sing-tun v0.6.6-0.20250428031943-0686f8c4f210 // indirect 97 | github.com/sagernet/sing-vmess v0.2.2-0.20250503051933-9b4cf17393f8 // indirect 98 | github.com/sagernet/smux v1.5.34-mod.2 // indirect 99 | github.com/sagernet/tailscale v1.80.3-mod.5 // indirect 100 | github.com/sagernet/wireguard-go v0.0.1-beta.7 // indirect 101 | github.com/sagernet/ws v0.0.0-20231204124109-acfe8907c854 // indirect 102 | github.com/samber/lo v1.38.1 // indirect 103 | github.com/sirupsen/logrus v1.9.3 // indirect 104 | github.com/spf13/pflag v1.0.6 // indirect 105 | github.com/tailscale/certstore v0.1.1-0.20231202035212-d3fa0460f47e // indirect 106 | github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 // indirect 107 | github.com/tailscale/golang-x-crypto v0.0.0-20240604161659-3fde5e568aa4 // indirect 108 | github.com/tailscale/goupnp v1.0.1-0.20210804011211-c64d0f06ea05 // indirect 109 | github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a // indirect 110 | github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7 // indirect 111 | github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc // indirect 112 | github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976 // indirect 113 | github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701 // indirect 114 | github.com/vishvananda/netlink v1.2.1-beta.2.0.20230420174744-55c8b9515a01 // indirect 115 | github.com/vishvananda/netns v0.0.5 // indirect 116 | github.com/x448/float16 v0.8.4 // indirect 117 | github.com/zeebo/blake3 v0.2.4 // indirect 118 | go.etcd.io/bbolt v1.3.7 // indirect 119 | go.uber.org/atomic v1.11.0 // indirect 120 | go.uber.org/multierr v1.11.0 // indirect 121 | go.uber.org/zap v1.27.0 // indirect 122 | go.uber.org/zap/exp v0.3.0 // indirect 123 | go4.org/mem v0.0.0-20240501181205-ae6ca9944745 // indirect 124 | go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect 125 | golang.org/x/crypto v0.38.0 // indirect 126 | golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 // indirect 127 | golang.org/x/sync v0.14.0 // indirect 128 | golang.org/x/sys v0.33.0 // indirect 129 | golang.org/x/term v0.32.0 // indirect 130 | golang.org/x/text v0.25.0 // indirect 131 | golang.org/x/time v0.9.0 // indirect 132 | golang.org/x/tools v0.33.0 // indirect 133 | golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect 134 | golang.zx2c4.com/wireguard/windows v0.5.3 // indirect 135 | google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a // indirect 136 | google.golang.org/grpc v1.72.0 // indirect 137 | google.golang.org/protobuf v1.36.6 // indirect 138 | gopkg.in/yaml.v3 v3.0.1 // indirect 139 | lukechampine.com/blake3 v1.3.0 // indirect 140 | ) 141 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: serenity 2 | site_author: nekohasekai 3 | repo_url: https://github.com/SagerNet/serenity 4 | repo_name: SagerNet/serenity 5 | copyright: Copyright © 2022 nekohasekai 6 | site_description: The configuration generator for sing-box. 7 | remote_branch: docs 8 | edit_uri: "" 9 | theme: 10 | name: material 11 | logo: assets/icon.svg 12 | favicon: assets/icon.svg 13 | palette: 14 | - scheme: default 15 | primary: white 16 | toggle: 17 | icon: material/brightness-7 18 | name: Switch to dark mode 19 | - scheme: slate 20 | primary: black 21 | toggle: 22 | icon: material/brightness-4 23 | name: Switch to light mode 24 | features: 25 | # - navigation.instant 26 | - navigation.tracking 27 | - navigation.tabs 28 | - navigation.indexes 29 | - navigation.expand 30 | - navigation.sections 31 | - header.autohide 32 | - content.code.copy 33 | - content.code.select 34 | - content.code.annotate 35 | icon: 36 | admonition: 37 | question: material/new-box 38 | nav: 39 | - Home: 40 | - index.md 41 | - Change Log: changelog.md 42 | - Support: support.md 43 | - Installation: 44 | - Package Manager: installation/package-manager.md 45 | - Docker: installation/docker.md 46 | - Build from source: installation/build-from-source.md 47 | - Configuration: 48 | - configuration/index.md 49 | - Subscription: configuration/subscription.md 50 | - Template: configuration/template.md 51 | - Profile: configuration/profile.md 52 | - User: configuration/user.md 53 | - Shared: 54 | - RuleSet: configuration/shared/rule-set.md 55 | markdown_extensions: 56 | - pymdownx.inlinehilite 57 | - pymdownx.snippets 58 | - pymdownx.superfences 59 | - pymdownx.details 60 | - pymdownx.critic 61 | - pymdownx.caret 62 | - pymdownx.keys 63 | - pymdownx.mark 64 | - pymdownx.tilde 65 | - pymdownx.magiclink 66 | - admonition 67 | - attr_list 68 | - md_in_html 69 | - footnotes 70 | - def_list 71 | - pymdownx.highlight: 72 | anchor_linenums: true 73 | - pymdownx.tabbed: 74 | alternate_style: true 75 | - pymdownx.tasklist: 76 | custom_checkbox: true 77 | - pymdownx.emoji: 78 | emoji_index: !!python/name:material.extensions.emoji.twemoji 79 | emoji_generator: !!python/name:material.extensions.emoji.to_svg 80 | - pymdownx.superfences: 81 | custom_fences: 82 | - name: mermaid 83 | class: mermaid 84 | format: !!python/name:pymdownx.superfences.fence_code_format 85 | extra: 86 | social: 87 | - icon: fontawesome/brands/github 88 | link: https://github.com/SagerNet/serenity 89 | generator: false 90 | plugins: 91 | - search 92 | - i18n: 93 | docs_structure: suffix 94 | fallback_to_default: true 95 | languages: 96 | - build: true 97 | default: true 98 | locale: en 99 | name: English 100 | - build: true 101 | default: false 102 | locale: zh 103 | name: 简体中文 104 | nav_translations: 105 | Home: 开始 106 | Change Log: 更新日志 107 | Support: 支持 108 | 109 | Installation: 安装 110 | Package Manager: 包管理器 111 | Build from source: 从源代码构建 112 | 113 | Configuration: 配置 114 | reconfigure_material: true 115 | reconfigure_search: true -------------------------------------------------------------------------------- /option/message.go: -------------------------------------------------------------------------------- 1 | package option 2 | 3 | import ( 4 | "github.com/sagernet/sing/common/json" 5 | ) 6 | 7 | type TypedMessage[T any] struct { 8 | Message json.RawMessage 9 | Value T 10 | } 11 | 12 | func (m *TypedMessage[T]) MarshalJSON() ([]byte, error) { 13 | return json.Marshal(m.Value) 14 | } 15 | 16 | func (m *TypedMessage[T]) UnmarshalJSON(content []byte) error { 17 | m.Message = content 18 | return json.UnmarshalDisallowUnknownFields(content, &m.Value) 19 | } 20 | -------------------------------------------------------------------------------- /option/options.go: -------------------------------------------------------------------------------- 1 | package option 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | "github.com/sagernet/sing-box/option" 8 | "github.com/sagernet/sing/common/json" 9 | "github.com/sagernet/sing/common/json/badjson" 10 | "github.com/sagernet/sing/common/json/badoption" 11 | ) 12 | 13 | type _Options struct { 14 | RawMessage json.RawMessage `json:"-"` 15 | Log *option.LogOptions `json:"log,omitempty"` 16 | Listen string `json:"listen,omitempty"` 17 | TLS *option.InboundTLSOptions `json:"tls,omitempty"` 18 | CacheFile string `json:"cache_file,omitempty"` 19 | 20 | Outbounds []badoption.Listable[option.Outbound] `json:"outbounds,omitempty"` 21 | Endpoints badoption.Listable[option.Endpoint] `json:"endpoints,omitempty"` 22 | Subscriptions []Subscription `json:"subscriptions,omitempty"` 23 | Templates []Template `json:"templates,omitempty"` 24 | Profiles []Profile `json:"profiles,omitempty"` 25 | Users []User `json:"users,omitempty"` 26 | } 27 | 28 | type Options _Options 29 | 30 | func (o *Options) UnmarshalJSONContext(ctx context.Context, content []byte) error { 31 | err := json.UnmarshalContextDisallowUnknownFields(ctx, content, (*_Options)(o)) 32 | if err != nil { 33 | return err 34 | } 35 | o.RawMessage = content 36 | return nil 37 | } 38 | 39 | type User struct { 40 | Name string `json:"name,omitempty"` 41 | Password string `json:"password,omitempty"` 42 | Profile badoption.Listable[string] `json:"profile,omitempty"` 43 | DefaultProfile string `json:"default_profile,omitempty"` 44 | } 45 | 46 | const ( 47 | DefaultSubscriptionUpdateInterval = 1 * time.Hour 48 | ) 49 | 50 | type Subscription struct { 51 | Name string `json:"name,omitempty"` 52 | URL string `json:"url,omitempty"` 53 | UserAgent string `json:"user_agent,omitempty"` 54 | UpdateInterval badoption.Duration `json:"update_interval,omitempty"` 55 | Process badoption.Listable[OutboundProcessOptions] `json:"process,omitempty"` 56 | DeDuplication bool `json:"deduplication,omitempty"` 57 | GenerateSelector bool `json:"generate_selector,omitempty"` 58 | GenerateURLTest bool `json:"generate_urltest,omitempty"` 59 | URLTestTagSuffix string `json:"urltest_suffix,omitempty"` 60 | CustomSelector *option.SelectorOutboundOptions `json:"custom_selector,omitempty"` 61 | CustomURLTest *option.URLTestOutboundOptions `json:"custom_urltest,omitempty"` 62 | } 63 | 64 | type OutboundProcessOptions struct { 65 | Filter badoption.Listable[string] `json:"filter,omitempty"` 66 | Exclude badoption.Listable[string] `json:"exclude,omitempty"` 67 | FilterType badoption.Listable[string] `json:"filter_type,omitempty"` 68 | ExcludeType badoption.Listable[string] `json:"exclude_type,omitempty"` 69 | Invert bool `json:"invert,omitempty"` 70 | Remove bool `json:"remove,omitempty"` 71 | Rename *badjson.TypedMap[string, string] `json:"rename,omitempty"` 72 | RemoveEmoji bool `json:"remove_emoji,omitempty"` 73 | RewriteMultiplex *option.OutboundMultiplexOptions `json:"rewrite_multiplex,omitempty"` 74 | } 75 | 76 | type Profile struct { 77 | Name string `json:"name,omitempty"` 78 | Template string `json:"template,omitempty"` 79 | TemplateForPlatform *badjson.TypedMap[string, string] `json:"template_for_platform,omitempty"` 80 | TemplateForUserAgent *badjson.TypedMap[string, string] `json:"template_for_user_agent,omitempty"` 81 | Outbound badoption.Listable[string] `json:"outbound,omitempty"` 82 | Endpoint badoption.Listable[string] `json:"endpoint,omitempty"` 83 | Subscription badoption.Listable[string] `json:"subscription,omitempty"` 84 | } 85 | -------------------------------------------------------------------------------- /option/template.go: -------------------------------------------------------------------------------- 1 | package option 2 | 3 | import ( 4 | "context" 5 | 6 | C "github.com/sagernet/serenity/constant" 7 | "github.com/sagernet/sing-box/option" 8 | "github.com/sagernet/sing-dns" 9 | "github.com/sagernet/sing/common/byteformats" 10 | E "github.com/sagernet/sing/common/exceptions" 11 | "github.com/sagernet/sing/common/json" 12 | "github.com/sagernet/sing/common/json/badjson" 13 | "github.com/sagernet/sing/common/json/badoption" 14 | ) 15 | 16 | type _Template struct { 17 | RawMessage json.RawMessage `json:"-"` 18 | Name string `json:"name,omitempty"` 19 | Extend string `json:"extend,omitempty"` 20 | 21 | // Global 22 | 23 | Log *option.LogOptions `json:"log,omitempty"` 24 | DomainStrategy option.DomainStrategy `json:"domain_strategy,omitempty"` 25 | DomainStrategyLocal option.DomainStrategy `json:"domain_strategy_local,omitempty"` 26 | DisableTrafficBypass bool `json:"disable_traffic_bypass,omitempty"` 27 | DisableSniff bool `json:"disable_sniff,omitempty"` 28 | DisableRuleAction bool `json:"disable_rule_action,omitempty"` 29 | RemoteResolve bool `json:"remote_resolve,omitempty"` 30 | 31 | // DNS 32 | DNSServers []option.DNSServerOptions `json:"dns_servers,omitempty"` 33 | DNS string `json:"dns,omitempty"` 34 | DNSLocal string `json:"dns_local,omitempty"` 35 | EnableFakeIP bool `json:"enable_fakeip,omitempty"` 36 | DisableDNSLeak bool `json:"disable_dns_leak,omitempty"` 37 | PreDNSRules []option.DNSRule `json:"pre_dns_rules,omitempty"` 38 | CustomDNSRules []option.DNSRule `json:"custom_dns_rules,omitempty"` 39 | CustomFakeIP *option.FakeIPDNSServerOptions `json:"custom_fakeip,omitempty"` 40 | 41 | // Inbound 42 | Inbounds []option.Inbound `json:"inbounds,omitempty"` 43 | AutoRedirect bool `json:"auto_redirect,omitempty"` 44 | DisableTUN bool `json:"disable_tun,omitempty"` 45 | DisableSystemProxy bool `json:"disable_system_proxy,omitempty"` 46 | CustomTUN *TypedMessage[option.TunInboundOptions] `json:"custom_tun,omitempty"` 47 | CustomMixed *TypedMessage[option.HTTPMixedInboundOptions] `json:"custom_mixed,omitempty"` 48 | 49 | // Outbound 50 | ExtraGroups []ExtraGroup `json:"extra_groups,omitempty"` 51 | DirectTag string `json:"direct_tag,omitempty"` 52 | BlockTag string `json:"block_tag,omitempty"` 53 | DefaultTag string `json:"default_tag,omitempty"` 54 | URLTestTag string `json:"urltest_tag,omitempty"` 55 | CustomDirect *option.DirectOutboundOptions `json:"custom_direct,omitempty"` 56 | CustomSelector *option.SelectorOutboundOptions `json:"custom_selector,omitempty"` 57 | CustomURLTest *option.URLTestOutboundOptions `json:"custom_urltest,omitempty"` 58 | 59 | // Route 60 | DisableDefaultRules bool `json:"disable_default_rules,omitempty"` 61 | PreRules []option.Rule `json:"pre_rules,omitempty"` 62 | CustomRules []option.Rule `json:"custom_rules,omitempty"` 63 | EnableJSDelivr bool `json:"enable_jsdelivr,omitempty"` 64 | CustomRuleSet []RuleSet `json:"custom_rule_set,omitempty"` 65 | PostRuleSet []RuleSet `json:"post_rule_set,omitempty"` 66 | 67 | // Experimental 68 | DisableCacheFile bool `json:"disable_cache_file,omitempty"` 69 | DisableExternalController bool `json:"disable_external_controller,omitempty"` 70 | DisableClashMode bool `json:"disable_clash_mode,omitempty"` 71 | 72 | ClashModeLeak string `json:"clash_mode_leak,omitempty"` 73 | ClashModeRule string `json:"clash_mode_rule,omitempty"` 74 | ClashModeGlobal string `json:"clash_mode_global,omitempty"` 75 | ClashModeDirect string `json:"clash_mode_direct,omitempty"` 76 | CustomClashAPI *TypedMessage[option.ClashAPIOptions] `json:"custom_clash_api,omitempty"` 77 | 78 | // Debug 79 | PProfListen string `json:"pprof_listen,omitempty"` 80 | MemoryLimit *byteformats.MemoryBytes `json:"memory_limit,omitempty"` 81 | } 82 | 83 | type Template _Template 84 | 85 | func (t *Template) MarshalJSON() ([]byte, error) { 86 | return json.Marshal((*_Template)(t)) 87 | } 88 | 89 | func (t *Template) UnmarshalJSONContext(ctx context.Context, content []byte) error { 90 | err := json.UnmarshalContextDisallowUnknownFields(ctx, content, (*_Template)(t)) 91 | if err != nil { 92 | return err 93 | } 94 | t.RawMessage = content 95 | return nil 96 | } 97 | 98 | type _RuleSet struct { 99 | Type string `json:"type,omitempty"` 100 | DefaultOptions option.RuleSet `json:"-"` 101 | GitHubOptions GitHubRuleSetOptions `json:"-"` 102 | } 103 | 104 | type RuleSet _RuleSet 105 | 106 | func (r *RuleSet) MarshalJSON() ([]byte, error) { 107 | if r.Type == C.RuleSetTypeGitHub { 108 | return badjson.MarshallObjects((*_RuleSet)(r), r.GitHubOptions) 109 | } else { 110 | return json.Marshal(r.DefaultOptions) 111 | } 112 | } 113 | 114 | func (r *RuleSet) UnmarshalJSON(content []byte) error { 115 | err := json.Unmarshal(content, (*_RuleSet)(r)) 116 | if err != nil { 117 | return err 118 | } 119 | if r.Type == C.RuleSetTypeGitHub { 120 | return badjson.UnmarshallExcluded(content, (*_RuleSet)(r), &r.GitHubOptions) 121 | } else { 122 | return badjson.UnmarshallExcluded(content, (*_RuleSet)(r), &r.DefaultOptions) 123 | } 124 | } 125 | 126 | type GitHubRuleSetOptions struct { 127 | Repository string `json:"repository,omitempty"` 128 | Path string `json:"path,omitempty"` 129 | Prefix string `json:"prefix,omitempty"` 130 | RuleSet badoption.Listable[string] `json:"rule_set,omitempty"` 131 | } 132 | 133 | func (t Template) DisableIPv6() bool { 134 | return t.DomainStrategy == option.DomainStrategy(dns.DomainStrategyUseIPv4) && t.DomainStrategyLocal == option.DomainStrategy(dns.DomainStrategyUseIPv4) 135 | } 136 | 137 | type ExtraGroup struct { 138 | Tag string `json:"tag,omitempty"` 139 | Target ExtraGroupTarget `json:"target,omitempty"` 140 | TagPerSubscription string `json:"tag_per_subscription,omitempty"` 141 | Type string `json:"type,omitempty"` 142 | Filter badoption.Listable[string] `json:"filter,omitempty"` 143 | Exclude badoption.Listable[string] `json:"exclude,omitempty"` 144 | CustomSelector *option.SelectorOutboundOptions `json:"custom_selector,omitempty"` 145 | CustomURLTest *option.URLTestOutboundOptions `json:"custom_urltest,omitempty"` 146 | } 147 | 148 | type ExtraGroupTarget uint8 149 | 150 | const ( 151 | ExtraGroupTargetDefault ExtraGroupTarget = iota 152 | ExtraGroupTargetGlobal 153 | ExtraGroupTargetSubscription 154 | ) 155 | 156 | func (t ExtraGroupTarget) String() string { 157 | switch t { 158 | case ExtraGroupTargetDefault: 159 | return "default" 160 | case ExtraGroupTargetGlobal: 161 | return "global" 162 | case ExtraGroupTargetSubscription: 163 | return "subscription" 164 | default: 165 | return "unknown" 166 | } 167 | } 168 | 169 | func (t ExtraGroupTarget) MarshalJSON() ([]byte, error) { 170 | return json.Marshal(t.String()) 171 | } 172 | 173 | func (t *ExtraGroupTarget) UnmarshalJSON(bytes []byte) error { 174 | var stringValue string 175 | err := json.Unmarshal(bytes, &stringValue) 176 | if err != nil { 177 | return err 178 | } 179 | switch stringValue { 180 | case "default": 181 | *t = ExtraGroupTargetDefault 182 | case "global": 183 | *t = ExtraGroupTargetGlobal 184 | case "subscription": 185 | *t = ExtraGroupTargetSubscription 186 | default: 187 | return E.New("unknown extra group target: ", stringValue) 188 | } 189 | return nil 190 | } 191 | -------------------------------------------------------------------------------- /release/config/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "listen": ":8080", 3 | "users": [], 4 | "subscriptions": [], 5 | "outbounds": [], 6 | "templates": [], 7 | "profiles": [] 8 | } 9 | -------------------------------------------------------------------------------- /release/config/serenity.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=serenity service 3 | Documentation=https://serenity.sagernet.org 4 | After=network.target nss-lookup.target 5 | 6 | [Service] 7 | CapabilityBoundingSet=CAP_NET_ADMIN CAP_NET_BIND_SERVICE CAP_SYS_PTRACE CAP_DAC_READ_SEARCH 8 | AmbientCapabilities=CAP_NET_ADMIN CAP_NET_BIND_SERVICE CAP_SYS_PTRACE CAP_DAC_READ_SEARCH 9 | ExecStart=/usr/bin/serenity -D /var/lib/serenity -C /etc/serenity run 10 | ExecReload=/bin/kill -HUP $MAINPID 11 | Restart=on-failure 12 | RestartSec=10s 13 | LimitNOFILE=infinity 14 | 15 | [Install] 16 | WantedBy=multi-user.target 17 | -------------------------------------------------------------------------------- /release/config/serenity@.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=serenity service 3 | Documentation=https://serenity.sagernet.org 4 | After=network.target nss-lookup.target 5 | 6 | [Service] 7 | CapabilityBoundingSet=CAP_NET_ADMIN CAP_NET_BIND_SERVICE CAP_SYS_PTRACE CAP_DAC_READ_SEARCH 8 | AmbientCapabilities=CAP_NET_ADMIN CAP_NET_BIND_SERVICE CAP_SYS_PTRACE CAP_DAC_READ_SEARCH 9 | ExecStart=/usr/bin/serenity -D /var/lib/serenity-%i -c /etc/serenity/%i.json run 10 | ExecReload=/bin/kill -HUP $MAINPID 11 | Restart=on-failure 12 | RestartSec=10s 13 | LimitNOFILE=infinity 14 | 15 | [Install] 16 | WantedBy=multi-user.target 17 | -------------------------------------------------------------------------------- /release/local/enable.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e -o pipefail 4 | 5 | sudo systemctl enable serenity 6 | sudo systemctl start serenity 7 | sudo journalctl -u serenity --output cat -f 8 | -------------------------------------------------------------------------------- /release/local/install.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e -o pipefail 4 | 5 | if [ -d /usr/local/go ]; then 6 | export PATH="$PATH:/usr/local/go/bin" 7 | fi 8 | 9 | DIR=$(dirname "$0") 10 | PROJECT=$DIR/../.. 11 | 12 | pushd $PROJECT 13 | go install -v -trimpath -ldflags "-s -w -buildid=" ./cmd/serenity 14 | popd 15 | 16 | sudo cp $(go env GOPATH)/bin/serenity /usr/local/bin/ 17 | sudo mkdir -p /usr/local/etc/serenity 18 | sudo cp $PROJECT/release/config/config.json /usr/local/etc/serenity/config.json 19 | sudo cp $DIR/serenity.service /etc/systemd/system 20 | sudo systemctl daemon-reload 21 | -------------------------------------------------------------------------------- /release/local/install_go.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e -o pipefail 4 | 5 | go_version=$(curl -s https://raw.githubusercontent.com/actions/go-versions/main/versions-manifest.json | grep -oE '"version": "[0-9]{1}.[0-9]{1,}(.[0-9]{1,})?"' | head -1 | cut -d':' -f2 | sed 's/ //g; s/"//g') 6 | curl -Lo go.tar.gz "https://go.dev/dl/go$go_version.linux-amd64.tar.gz" 7 | sudo rm -rf /usr/local/go 8 | sudo tar -C /usr/local -xzf go.tar.gz 9 | rm go.tar.gz 10 | -------------------------------------------------------------------------------- /release/local/reinstall.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e -o pipefail 4 | 5 | if [ -d /usr/local/go ]; then 6 | export PATH="$PATH:/usr/local/go/bin" 7 | fi 8 | 9 | DIR=$(dirname "$0") 10 | PROJECT=$DIR/../.. 11 | 12 | pushd $PROJECT 13 | go install -v -trimpath -ldflags "-s -w -buildid=" ./cmd/serenity 14 | popd 15 | 16 | sudo systemctl stop serenity 17 | sudo cp $(go env GOPATH)/bin/serenity /usr/local/bin/ 18 | sudo systemctl start serenity 19 | -------------------------------------------------------------------------------- /release/local/serenity.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=serenity service 3 | Documentation=https://serenity.sagernet.org 4 | After=network.target nss-lookup.target 5 | 6 | [Service] 7 | CapabilityBoundingSet=CAP_NET_ADMIN CAP_NET_BIND_SERVICE CAP_SYS_PTRACE CAP_DAC_READ_SEARCH 8 | AmbientCapabilities=CAP_NET_ADMIN CAP_NET_BIND_SERVICE CAP_SYS_PTRACE CAP_DAC_READ_SEARCH 9 | ExecStart=/usr/local/bin/serenity -D /var/lib/serenity -C /usr/local/etc/serenity run 10 | ExecReload=/bin/kill -HUP $MAINPID 11 | Restart=on-failure 12 | RestartSec=10s 13 | LimitNOFILE=infinity 14 | 15 | [Install] 16 | WantedBy=multi-user.target 17 | -------------------------------------------------------------------------------- /release/local/uninstall.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | sudo systemctl stop serenity 4 | sudo rm -rf /var/lib/serenity 5 | sudo rm -rf /usr/local/bin/serenity 6 | sudo rm -rf /usr/local/etc/serenity 7 | sudo rm -rf /etc/systemd/system/serenity.service 8 | sudo systemctl daemon-reload 9 | -------------------------------------------------------------------------------- /release/local/update.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e -o pipefail 4 | 5 | DIR=$(dirname "$0") 6 | PROJECT=$DIR/../.. 7 | 8 | pushd $PROJECT 9 | git fetch 10 | git reset FETCH_HEAD --hard 11 | git clean -fdx 12 | popd 13 | 14 | $DIR/reinstall.sh -------------------------------------------------------------------------------- /server/profile.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "context" 5 | "regexp" 6 | 7 | "github.com/sagernet/serenity/common/metadata" 8 | "github.com/sagernet/serenity/common/semver" 9 | "github.com/sagernet/serenity/option" 10 | "github.com/sagernet/serenity/subscription" 11 | "github.com/sagernet/serenity/template" 12 | boxOption "github.com/sagernet/sing-box/option" 13 | "github.com/sagernet/sing/common" 14 | E "github.com/sagernet/sing/common/exceptions" 15 | "github.com/sagernet/sing/common/json/badjson" 16 | "github.com/sagernet/sing/common/logger" 17 | ) 18 | 19 | type ProfileManager struct { 20 | ctx context.Context 21 | logger logger.Logger 22 | subscription *subscription.Manager 23 | outbounds [][]boxOption.Outbound 24 | endpoints []boxOption.Endpoint 25 | profiles []*Profile 26 | defaultProfile *Profile 27 | } 28 | 29 | type Profile struct { 30 | option.Profile 31 | manager *ProfileManager 32 | template *template.Template 33 | templateForPlatform map[metadata.Platform]*template.Template 34 | templateForUserAgent map[*regexp.Regexp]*template.Template 35 | groups []ExtraGroup 36 | } 37 | 38 | type ExtraGroup struct { 39 | option.ExtraGroup 40 | filterRegex []*regexp.Regexp 41 | } 42 | 43 | func NewProfileManager( 44 | ctx context.Context, 45 | logger logger.Logger, 46 | subscriptionManager *subscription.Manager, 47 | templateManager *template.Manager, 48 | outbounds [][]boxOption.Outbound, 49 | endpoints []boxOption.Endpoint, 50 | rawProfiles []option.Profile, 51 | ) (*ProfileManager, error) { 52 | manager := &ProfileManager{ 53 | ctx: ctx, 54 | logger: logger, 55 | subscription: subscriptionManager, 56 | outbounds: outbounds, 57 | endpoints: endpoints, 58 | } 59 | for profileIndex, profile := range rawProfiles { 60 | if profile.Name == "" { 61 | return nil, E.New("initialize profile[", profileIndex, "]: missing name") 62 | } 63 | var ( 64 | defaultTemplate *template.Template 65 | templateForPlatform = make(map[metadata.Platform]*template.Template) 66 | templateForUserAgent = make(map[*regexp.Regexp]*template.Template) 67 | ) 68 | if profile.Template != "" { 69 | defaultTemplate = templateManager.TemplateByName(profile.Template) 70 | if defaultTemplate == nil { 71 | return nil, E.New("initialize profile[", profile.Name, "]: template not found: ", profile.Template) 72 | } 73 | } else { 74 | defaultTemplate = template.Default 75 | } 76 | if profile.TemplateForPlatform != nil { 77 | for templateIndex, entry := range profile.TemplateForPlatform.Entries() { 78 | platform, err := metadata.ParsePlatform(entry.Key) 79 | if err != nil { 80 | return nil, E.Cause(err, "initialize profile[", profile.Name, "]: parse template_for_platform[", templateIndex, "]") 81 | } 82 | customTemplate := templateManager.TemplateByName(entry.Value) 83 | if customTemplate == nil { 84 | return nil, E.New("initialize profile[", profile.Name, "]: parse template_for_platform[", entry.Key, "]: template not found: ", entry.Value) 85 | } 86 | templateForPlatform[platform] = customTemplate 87 | } 88 | } 89 | if profile.TemplateForUserAgent != nil { 90 | for templateIndex, entry := range profile.TemplateForUserAgent.Entries() { 91 | regex, err := regexp.Compile(entry.Key) 92 | if err != nil { 93 | return nil, E.Cause(err, "initialize profile[", profile.Name, "]: parse template_for_user_agent[", templateIndex, "]") 94 | } 95 | customTemplate := templateManager.TemplateByName(entry.Value) 96 | if customTemplate == nil { 97 | return nil, E.New("initialize profile[", profile.Name, "]: parse template_for_user_agent[", entry.Key, "]: template not found: ", entry.Value) 98 | } 99 | templateForUserAgent[regex] = customTemplate 100 | } 101 | } 102 | manager.profiles = append(manager.profiles, &Profile{ 103 | Profile: profile, 104 | manager: manager, 105 | template: defaultTemplate, 106 | templateForPlatform: templateForPlatform, 107 | templateForUserAgent: templateForUserAgent, 108 | }) 109 | } 110 | if len(manager.profiles) > 0 { 111 | manager.defaultProfile = manager.profiles[0] 112 | } 113 | return manager, nil 114 | } 115 | 116 | func (m *ProfileManager) ProfileByName(name string) *Profile { 117 | for _, it := range m.profiles { 118 | if it.Name == name { 119 | return it 120 | } 121 | } 122 | return nil 123 | } 124 | 125 | func (m *ProfileManager) DefaultProfile() *Profile { 126 | return m.defaultProfile 127 | } 128 | 129 | func (p *Profile) Render(metadata metadata.Metadata) (*boxOption.Options, error) { 130 | selectedTemplate, loaded := p.templateForPlatform[metadata.Platform] 131 | if !loaded { 132 | for regex, it := range p.templateForUserAgent { 133 | if regex.MatchString(metadata.UserAgent) { 134 | selectedTemplate = it 135 | break 136 | } 137 | } 138 | } 139 | if selectedTemplate == nil { 140 | selectedTemplate = p.template 141 | } 142 | outbounds := common.Filter(p.manager.outbounds, func(it []boxOption.Outbound) bool { 143 | return common.Contains(p.Outbound, it[0].Tag) 144 | }) 145 | endpoints := common.Filter(p.manager.endpoints, func(it boxOption.Endpoint) bool { 146 | return common.Contains(p.Endpoint, it.Tag) 147 | }) 148 | var subscriptions []*subscription.Subscription 149 | for _, subscriptionName := range p.Subscription { 150 | subscription := common.Find(p.manager.subscription.Subscriptions(), func(it *subscription.Subscription) bool { 151 | return it.Name == subscriptionName 152 | }) 153 | if subscription == nil { 154 | return nil, E.New("render profile[", p.Name, "]: subscription not found: ", subscriptionName) 155 | } 156 | subscriptions = append(subscriptions, subscription) 157 | } 158 | ctx := p.manager.ctx 159 | if metadata.Version == nil || metadata.Version.LessThan(semver.ParseVersion("1.12.0-alpha.1")) { 160 | ctx = boxOption.ContextWithDontUpgrade(ctx) 161 | } 162 | options, err := selectedTemplate.Render(ctx, metadata, p.Name, outbounds, subscriptions) 163 | if err != nil { 164 | return nil, err 165 | } 166 | if metadata.Version != nil && metadata.Version.GreaterThanOrEqual(semver.ParseVersion("1.12.0-alpha.1")) { 167 | options.Endpoints = endpoints 168 | } 169 | options, err = badjson.Omitempty(ctx, options) 170 | if err != nil { 171 | return nil, E.Cause(err, "omitempty") 172 | } 173 | return options, nil 174 | } 175 | -------------------------------------------------------------------------------- /server/server.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "net" 7 | "net/http" 8 | "os" 9 | "time" 10 | 11 | "github.com/sagernet/serenity/common/cachefile" 12 | "github.com/sagernet/serenity/option" 13 | "github.com/sagernet/serenity/subscription" 14 | "github.com/sagernet/serenity/template" 15 | "github.com/sagernet/sing-box/common/tls" 16 | "github.com/sagernet/sing-box/log" 17 | boxOption "github.com/sagernet/sing-box/option" 18 | "github.com/sagernet/sing/common" 19 | E "github.com/sagernet/sing/common/exceptions" 20 | F "github.com/sagernet/sing/common/format" 21 | "github.com/sagernet/sing/common/json/badoption" 22 | "github.com/sagernet/sing/service" 23 | 24 | "github.com/go-chi/chi/v5" 25 | "golang.org/x/net/http2" 26 | ) 27 | 28 | type Server struct { 29 | createdAt time.Time 30 | ctx context.Context 31 | logFactory log.Factory 32 | logger log.Logger 33 | chiRouter chi.Router 34 | httpServer *http.Server 35 | tlsConfig tls.ServerConfig 36 | cacheFile *cachefile.CacheFile 37 | subscription *subscription.Manager 38 | template *template.Manager 39 | profile *ProfileManager 40 | users []option.User 41 | userMap map[string][]option.User 42 | } 43 | 44 | func New(ctx context.Context, options option.Options) (*Server, error) { 45 | ctx = service.ContextWithDefaultRegistry(ctx) 46 | createdAt := time.Now() 47 | logFactory, err := log.New(log.Options{ 48 | Context: ctx, 49 | Options: common.PtrValueOrDefault(options.Log), 50 | DefaultWriter: os.Stderr, 51 | BaseTime: createdAt, 52 | }) 53 | if err != nil { 54 | return nil, E.Cause(err, "create log factory") 55 | } 56 | 57 | chiRouter := chi.NewRouter() 58 | httpServer := &http.Server{ 59 | Addr: options.Listen, 60 | Handler: chiRouter, 61 | } 62 | if httpServer.Addr == "" { 63 | if options.TLS != nil && options.TLS.Enabled { 64 | httpServer.Addr = ":443" 65 | } else { 66 | httpServer.Addr = ":80" 67 | } 68 | } 69 | var tlsConfig tls.ServerConfig 70 | if options.TLS != nil { 71 | tlsConfig, err = tls.NewServer(ctx, logFactory.NewLogger("tls"), common.PtrValueOrDefault(options.TLS)) 72 | if err != nil { 73 | return nil, err 74 | } 75 | } 76 | var cacheFilePath string 77 | if options.CacheFile != "" { 78 | cacheFilePath = options.CacheFile 79 | } else { 80 | cacheFilePath = "cache.db" 81 | } 82 | cacheFile := cachefile.New(cacheFilePath) 83 | subscriptionManager, err := subscription.NewSubscriptionManager( 84 | ctx, 85 | logFactory.NewLogger("subscription"), 86 | cacheFile, 87 | options.Subscriptions) 88 | if err != nil { 89 | return nil, err 90 | } 91 | templateManager, err := template.NewManager( 92 | ctx, 93 | logFactory.NewLogger("template"), 94 | options.Templates) 95 | if err != nil { 96 | return nil, err 97 | } 98 | profileManager, err := NewProfileManager( 99 | ctx, 100 | logFactory.NewLogger("profile"), 101 | subscriptionManager, 102 | templateManager, 103 | common.Map(options.Outbounds, func(it badoption.Listable[boxOption.Outbound]) []boxOption.Outbound { 104 | return it 105 | }), 106 | options.Endpoints, 107 | options.Profiles, 108 | ) 109 | if err != nil { 110 | return nil, err 111 | } 112 | userMap := make(map[string][]option.User) 113 | for _, user := range options.Users { 114 | userMap[user.Name] = append(userMap[user.Name], user) 115 | } 116 | return &Server{ 117 | createdAt: createdAt, 118 | ctx: ctx, 119 | logFactory: logFactory, 120 | logger: logFactory.Logger(), 121 | chiRouter: chiRouter, 122 | httpServer: httpServer, 123 | tlsConfig: tlsConfig, 124 | cacheFile: cacheFile, 125 | subscription: subscriptionManager, 126 | template: templateManager, 127 | profile: profileManager, 128 | users: options.Users, 129 | userMap: userMap, 130 | }, nil 131 | } 132 | 133 | func (s *Server) Start() error { 134 | s.initializeRoutes() 135 | err := s.logFactory.Start() 136 | if err != nil { 137 | return err 138 | } 139 | err = s.cacheFile.Start() 140 | if err != nil { 141 | return err 142 | } 143 | err = s.subscription.Start() 144 | if err != nil { 145 | return err 146 | } 147 | listener, err := net.Listen("tcp", s.httpServer.Addr) 148 | if err != nil { 149 | return err 150 | } 151 | if s.tlsConfig != nil { 152 | err = s.tlsConfig.Start() 153 | if err != nil { 154 | return err 155 | } 156 | err = http2.ConfigureServer(s.httpServer, new(http2.Server)) 157 | if err != nil { 158 | return err 159 | } 160 | stdConfig, err := s.tlsConfig.Config() 161 | if err != nil { 162 | return err 163 | } 164 | s.httpServer.TLSConfig = stdConfig 165 | } 166 | s.logger.Info("server started at ", listener.Addr()) 167 | go func() { 168 | if s.httpServer.TLSConfig != nil { 169 | err = s.httpServer.ServeTLS(listener, "", "") 170 | } else { 171 | err = s.httpServer.Serve(listener) 172 | } 173 | if err != nil && !errors.Is(err, http.ErrServerClosed) { 174 | s.logger.Error("server serve error: ", err) 175 | } 176 | }() 177 | err = s.subscription.PostStart(false) 178 | if err != nil { 179 | return err 180 | } 181 | s.logger.Info("serenity started (", F.Seconds(time.Since(s.createdAt).Seconds()), "s)") 182 | return nil 183 | } 184 | 185 | func (s *Server) StartHeadless() error { 186 | err := s.logFactory.Start() 187 | if err != nil { 188 | return err 189 | } 190 | err = s.cacheFile.Start() 191 | if err != nil { 192 | return err 193 | } 194 | err = s.subscription.Start() 195 | if err != nil { 196 | return err 197 | } 198 | err = s.subscription.PostStart(true) 199 | if err != nil { 200 | return err 201 | } 202 | s.logger.Info("headless serenity started (", F.Seconds(time.Since(s.createdAt).Seconds()), "s)") 203 | return nil 204 | } 205 | 206 | func (s *Server) Close() error { 207 | return common.Close( 208 | s.logFactory, 209 | common.PtrOrNil(s.httpServer), 210 | s.tlsConfig, 211 | common.PtrOrNil(s.cacheFile), 212 | ) 213 | } 214 | -------------------------------------------------------------------------------- /server/server_render.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "bytes" 5 | "net/http" 6 | "strings" 7 | 8 | "github.com/sagernet/serenity/common/metadata" 9 | M "github.com/sagernet/serenity/common/metadata" 10 | "github.com/sagernet/serenity/option" 11 | boxOption "github.com/sagernet/sing-box/option" 12 | "github.com/sagernet/sing/common" 13 | E "github.com/sagernet/sing/common/exceptions" 14 | "github.com/sagernet/sing/common/json" 15 | 16 | "github.com/go-chi/chi/v5" 17 | "github.com/go-chi/cors" 18 | "github.com/go-chi/render" 19 | ) 20 | 21 | func (s *Server) initializeRoutes() { 22 | s.chiRouter.Use(cors.New(cors.Options{ 23 | AllowedOrigins: []string{"*"}, 24 | AllowedMethods: []string{"GET"}, 25 | AllowedHeaders: []string{"Content-Type", "Authorization"}, 26 | }).Handler) 27 | s.chiRouter.Get("/", s.render) 28 | s.chiRouter.Get("/{profileName}", s.render) 29 | } 30 | 31 | func (s *Server) render(writer http.ResponseWriter, request *http.Request) { 32 | profileName := chi.URLParam(request, "profileName") 33 | if profileName == "" { 34 | // compatibility with legacy versions 35 | profileName = request.URL.Query().Get("profile") 36 | } 37 | if strings.HasSuffix(profileName, "/") { 38 | profileName = profileName[:len(profileName)-1] 39 | } 40 | var profile *Profile 41 | if len(s.users) == 0 { 42 | if profileName == "" { 43 | profile = s.profile.DefaultProfile() 44 | } else { 45 | profile = s.profile.ProfileByName(profileName) 46 | } 47 | } else { 48 | user := s.authorization(request) 49 | if user == nil { 50 | writer.WriteHeader(http.StatusUnauthorized) 51 | s.accessLog(request, http.StatusUnauthorized, 0) 52 | return 53 | } 54 | if len(user.Profile) == 0 { 55 | writer.WriteHeader(http.StatusNotFound) 56 | s.accessLog(request, http.StatusNotFound, 0) 57 | return 58 | } 59 | if profileName == "" { 60 | profileName = user.DefaultProfile 61 | } 62 | if profileName == "" { 63 | profileName = user.Profile[0] 64 | } 65 | if !common.Contains(user.Profile, profileName) { 66 | writer.WriteHeader(http.StatusNotFound) 67 | s.accessLog(request, http.StatusNotFound, 0) 68 | return 69 | } 70 | profile = s.profile.ProfileByName(profileName) 71 | } 72 | if profile == nil { 73 | writer.WriteHeader(http.StatusNotFound) 74 | s.accessLog(request, http.StatusNotFound, 0) 75 | return 76 | } 77 | options, err := profile.Render(M.Detect(request.Header.Get("User-Agent"))) 78 | if err != nil { 79 | s.logger.Error(E.Cause(err, "render options")) 80 | render.Status(request, http.StatusInternalServerError) 81 | render.PlainText(writer, request, err.Error()) 82 | s.accessLog(request, http.StatusInternalServerError, len(err.Error())) 83 | return 84 | } 85 | var buffer bytes.Buffer 86 | encoder := json.NewEncoderContext(s.ctx, &buffer) 87 | encoder.SetIndent("", " ") 88 | err = encoder.Encode(&options) 89 | if err != nil { 90 | s.logger.Error(E.Cause(err, "marshal options")) 91 | render.Status(request, http.StatusInternalServerError) 92 | render.PlainText(writer, request, err.Error()) 93 | s.accessLog(request, http.StatusInternalServerError, len(err.Error())) 94 | return 95 | } 96 | writer.Header().Set("Content-Type", "application/json") 97 | writer.WriteHeader(http.StatusOK) 98 | writer.Write(buffer.Bytes()) 99 | s.accessLog(request, http.StatusOK, buffer.Len()) 100 | } 101 | 102 | func (s *Server) RenderHeadless(profileName string, metadata metadata.Metadata) (*boxOption.Options, error) { 103 | var profile *Profile 104 | if profileName == "" { 105 | s.profile.DefaultProfile() 106 | } else { 107 | profile = s.profile.ProfileByName(profileName) 108 | } 109 | if profile == nil { 110 | return nil, E.New("profile not found") 111 | } 112 | options, err := profile.Render(metadata) 113 | if err != nil { 114 | return nil, E.Cause(err, "render options") 115 | } 116 | return options, nil 117 | } 118 | 119 | func (s *Server) accessLog(request *http.Request, responseCode int, responseLen int) { 120 | var userString string 121 | if username, password, ok := request.BasicAuth(); ok { 122 | if responseCode == http.StatusUnauthorized { 123 | userString = username + ":" + password 124 | } else { 125 | userString = username 126 | } 127 | } 128 | s.logger.Debug("accepted ", request.RemoteAddr, " - ", userString, " \"", request.Method, " ", request.URL, " ", request.Proto, "\" ", responseCode, " ", responseLen, " \"", request.UserAgent(), "\"") 129 | } 130 | 131 | func (s *Server) authorization(request *http.Request) *option.User { 132 | username, password, ok := request.BasicAuth() 133 | if !ok { 134 | return nil 135 | } 136 | users, loaded := s.userMap[username] 137 | if !loaded { 138 | return nil 139 | } 140 | for _, user := range users { 141 | if user.Password == password { 142 | return &user 143 | } 144 | } 145 | return nil 146 | } 147 | -------------------------------------------------------------------------------- /subscription/deduplication.go: -------------------------------------------------------------------------------- 1 | package subscription 2 | 3 | import ( 4 | "context" 5 | "net/netip" 6 | "sync" 7 | 8 | "github.com/sagernet/sing-box/log" 9 | "github.com/sagernet/sing-box/option" 10 | dns "github.com/sagernet/sing-dns" 11 | "github.com/sagernet/sing/common" 12 | N "github.com/sagernet/sing/common/network" 13 | "github.com/sagernet/sing/common/task" 14 | ) 15 | 16 | func Deduplication(ctx context.Context, servers []option.Outbound) []option.Outbound { 17 | resolveCtx := &resolveContext{ 18 | ctx: ctx, 19 | dnsClient: dns.NewClient(dns.ClientOptions{ 20 | DisableExpire: true, 21 | Logger: log.NewNOPFactory().Logger(), 22 | }), 23 | dnsTransport: common.Must1(dns.NewTLSTransport(dns.TransportOptions{ 24 | Context: ctx, 25 | Dialer: N.SystemDialer, 26 | Address: "tls://1.1.1.1", 27 | ClientSubnet: netip.MustParsePrefix("114.114.114.114/24"), 28 | })), 29 | } 30 | 31 | uniqueServers := make([]netip.AddrPort, len(servers)) 32 | var ( 33 | resolveGroup task.Group 34 | resultAccess sync.Mutex 35 | ) 36 | for index, server := range servers { 37 | currentIndex := index 38 | currentServer := server 39 | resolveGroup.Append0(func(ctx context.Context) error { 40 | destination := resolveDestination(resolveCtx, currentServer) 41 | if destination.IsValid() { 42 | resultAccess.Lock() 43 | uniqueServers[currentIndex] = destination 44 | resultAccess.Unlock() 45 | } 46 | return nil 47 | }) 48 | } 49 | resolveGroup.Concurrency(5) 50 | _ = resolveGroup.Run(ctx) 51 | uniqueServerMap := make(map[netip.AddrPort]bool) 52 | var newServers []option.Outbound 53 | for index, server := range servers { 54 | destination := uniqueServers[index] 55 | if destination.IsValid() { 56 | if uniqueServerMap[destination] { 57 | continue 58 | } 59 | uniqueServerMap[destination] = true 60 | } 61 | newServers = append(newServers, server) 62 | } 63 | return newServers 64 | } 65 | 66 | type resolveContext struct { 67 | ctx context.Context 68 | dnsClient *dns.Client 69 | dnsTransport dns.Transport 70 | } 71 | 72 | func resolveDestination(ctx *resolveContext, server option.Outbound) netip.AddrPort { 73 | serverOptionsWrapper, loaded := server.Options.(option.ServerOptionsWrapper) 74 | if !loaded { 75 | return netip.AddrPort{} 76 | } 77 | serverOptions := serverOptionsWrapper.TakeServerOptions().Build() 78 | if serverOptions.IsIP() { 79 | return serverOptions.AddrPort() 80 | } 81 | if serverOptions.IsFqdn() { 82 | addresses, lookupErr := ctx.dnsClient.Lookup(ctx.ctx, ctx.dnsTransport, serverOptions.Fqdn, dns.QueryOptions{ 83 | Strategy: dns.DomainStrategyPreferIPv4, 84 | }) 85 | if lookupErr == nil && len(addresses) > 0 { 86 | return netip.AddrPortFrom(addresses[0], serverOptions.Port) 87 | } 88 | } 89 | return netip.AddrPort{} 90 | } 91 | -------------------------------------------------------------------------------- /subscription/parser/clash.go: -------------------------------------------------------------------------------- 1 | package parser 2 | 3 | import ( 4 | "context" 5 | "strings" 6 | 7 | C "github.com/sagernet/sing-box/constant" 8 | "github.com/sagernet/sing-box/option" 9 | E "github.com/sagernet/sing/common/exceptions" 10 | "github.com/sagernet/sing/common/format" 11 | "github.com/sagernet/sing/common/json/badoption" 12 | N "github.com/sagernet/sing/common/network" 13 | 14 | "github.com/Dreamacro/clash/adapter" 15 | clash_outbound "github.com/Dreamacro/clash/adapter/outbound" 16 | "github.com/Dreamacro/clash/common/structure" 17 | "github.com/Dreamacro/clash/config" 18 | "github.com/Dreamacro/clash/constant" 19 | ) 20 | 21 | func ParseClashSubscription(_ context.Context, content string) ([]option.Outbound, error) { 22 | config, err := config.UnmarshalRawConfig([]byte(content)) 23 | if err != nil { 24 | return nil, E.Cause(err, "parse clash config") 25 | } 26 | decoder := structure.NewDecoder(structure.Option{TagName: "proxy", WeaklyTypedInput: true}) 27 | var outbounds []option.Outbound 28 | for i, proxyMapping := range config.Proxy { 29 | proxy, err := adapter.ParseProxy(proxyMapping) 30 | if err != nil { 31 | return nil, E.Cause(err, "parse proxy ", i) 32 | } 33 | var outbound option.Outbound 34 | outbound.Tag = proxy.Name() 35 | switch proxy.Type() { 36 | case constant.Shadowsocks: 37 | ssOption := &clash_outbound.ShadowSocksOption{} 38 | err = decoder.Decode(proxyMapping, ssOption) 39 | if err != nil { 40 | return nil, err 41 | } 42 | outbound.Type = C.TypeShadowsocks 43 | outbound.Options = &option.ShadowsocksOutboundOptions{ 44 | ServerOptions: option.ServerOptions{ 45 | Server: ssOption.Server, 46 | ServerPort: uint16(ssOption.Port), 47 | }, 48 | Password: ssOption.Password, 49 | Method: clashShadowsocksCipher(ssOption.Cipher), 50 | Plugin: clashPluginName(ssOption.Plugin), 51 | PluginOptions: clashPluginOptions(ssOption.Plugin, ssOption.PluginOpts), 52 | Network: clashNetworks(ssOption.UDP), 53 | } 54 | case constant.ShadowsocksR: 55 | ssrOption := &clash_outbound.ShadowSocksROption{} 56 | err = decoder.Decode(proxyMapping, ssrOption) 57 | if err != nil { 58 | return nil, err 59 | } 60 | outbound.Type = C.TypeShadowsocksR 61 | outbound.Options = &option.ShadowsocksROutboundOptions{ 62 | ServerOptions: option.ServerOptions{ 63 | Server: ssrOption.Server, 64 | ServerPort: uint16(ssrOption.Port), 65 | }, 66 | Password: ssrOption.Password, 67 | Method: clashShadowsocksCipher(ssrOption.Cipher), 68 | Protocol: ssrOption.Protocol, 69 | ProtocolParam: ssrOption.ProtocolParam, 70 | Obfs: ssrOption.Obfs, 71 | ObfsParam: ssrOption.ObfsParam, 72 | Network: clashNetworks(ssrOption.UDP), 73 | } 74 | case constant.Trojan: 75 | trojanOption := &clash_outbound.TrojanOption{} 76 | err = decoder.Decode(proxyMapping, trojanOption) 77 | if err != nil { 78 | return nil, err 79 | } 80 | outbound.Type = C.TypeTrojan 81 | outbound.Options = &option.TrojanOutboundOptions{ 82 | ServerOptions: option.ServerOptions{ 83 | Server: trojanOption.Server, 84 | ServerPort: uint16(trojanOption.Port), 85 | }, 86 | Password: trojanOption.Password, 87 | OutboundTLSOptionsContainer: option.OutboundTLSOptionsContainer{ 88 | TLS: &option.OutboundTLSOptions{ 89 | Enabled: true, 90 | ALPN: trojanOption.ALPN, 91 | ServerName: trojanOption.SNI, 92 | Insecure: trojanOption.SkipCertVerify, 93 | }, 94 | }, 95 | Transport: clashTransport(trojanOption.Network, clash_outbound.HTTPOptions{}, clash_outbound.HTTP2Options{}, trojanOption.GrpcOpts, trojanOption.WSOpts), 96 | Network: clashNetworks(trojanOption.UDP), 97 | } 98 | case constant.Vmess: 99 | vmessOption := &clash_outbound.VmessOption{} 100 | err = decoder.Decode(proxyMapping, vmessOption) 101 | if err != nil { 102 | return nil, err 103 | } 104 | outbound.Type = C.TypeVMess 105 | outbound.Options = &option.VMessOutboundOptions{ 106 | ServerOptions: option.ServerOptions{ 107 | Server: vmessOption.Server, 108 | ServerPort: uint16(vmessOption.Port), 109 | }, 110 | UUID: vmessOption.UUID, 111 | Security: vmessOption.Cipher, 112 | AlterId: vmessOption.AlterID, 113 | OutboundTLSOptionsContainer: option.OutboundTLSOptionsContainer{ 114 | TLS: &option.OutboundTLSOptions{ 115 | Enabled: vmessOption.TLS, 116 | ServerName: vmessOption.ServerName, 117 | Insecure: vmessOption.SkipCertVerify, 118 | }, 119 | }, 120 | Transport: clashTransport(vmessOption.Network, vmessOption.HTTPOpts, vmessOption.HTTP2Opts, vmessOption.GrpcOpts, vmessOption.WSOpts), 121 | Network: clashNetworks(vmessOption.UDP), 122 | } 123 | case constant.Socks5: 124 | socks5Option := &clash_outbound.Socks5Option{} 125 | err = decoder.Decode(proxyMapping, socks5Option) 126 | if err != nil { 127 | return nil, err 128 | } 129 | 130 | if socks5Option.TLS { 131 | // TODO: print warning 132 | continue 133 | } 134 | 135 | outbound.Type = C.TypeSOCKS 136 | outbound.Options = &option.SOCKSOutboundOptions{ 137 | ServerOptions: option.ServerOptions{ 138 | Server: socks5Option.Server, 139 | ServerPort: uint16(socks5Option.Port), 140 | }, 141 | Username: socks5Option.UserName, 142 | Password: socks5Option.Password, 143 | Network: clashNetworks(socks5Option.UDP), 144 | } 145 | case constant.Http: 146 | httpOption := &clash_outbound.HttpOption{} 147 | err = decoder.Decode(proxyMapping, httpOption) 148 | if err != nil { 149 | return nil, err 150 | } 151 | 152 | if httpOption.TLS { 153 | continue 154 | } 155 | 156 | outbound.Type = C.TypeHTTP 157 | outbound.Options = &option.HTTPOutboundOptions{ 158 | ServerOptions: option.ServerOptions{ 159 | Server: httpOption.Server, 160 | ServerPort: uint16(httpOption.Port), 161 | }, 162 | Username: httpOption.UserName, 163 | Password: httpOption.Password, 164 | } 165 | } 166 | outbounds = append(outbounds, outbound) 167 | } 168 | if len(outbounds) > 0 { 169 | return outbounds, nil 170 | } 171 | return nil, E.New("no servers found") 172 | } 173 | 174 | func clashShadowsocksCipher(cipher string) string { 175 | switch cipher { 176 | case "dummy": 177 | return "none" 178 | } 179 | return cipher 180 | } 181 | 182 | func clashNetworks(udpEnabled bool) option.NetworkList { 183 | if !udpEnabled { 184 | return N.NetworkTCP 185 | } 186 | return "" 187 | } 188 | 189 | func clashPluginName(plugin string) string { 190 | switch plugin { 191 | case "obfs": 192 | return "obfs-local" 193 | } 194 | return plugin 195 | } 196 | 197 | type shadowsocksPluginOptionsBuilder map[string]any 198 | 199 | func (o shadowsocksPluginOptionsBuilder) Build() string { 200 | var opts []string 201 | for key, value := range o { 202 | if value == nil { 203 | continue 204 | } 205 | opts = append(opts, format.ToString(key, "=", value)) 206 | } 207 | return strings.Join(opts, ";") 208 | } 209 | 210 | func clashPluginOptions(plugin string, opts map[string]any) string { 211 | options := make(shadowsocksPluginOptionsBuilder) 212 | switch plugin { 213 | case "obfs": 214 | options["obfs"] = opts["mode"] 215 | options["obfs-host"] = opts["host"] 216 | case "v2ray-plugin": 217 | options["mode"] = opts["mode"] 218 | options["tls"] = opts["tls"] 219 | options["host"] = opts["host"] 220 | options["path"] = opts["path"] 221 | } 222 | return options.Build() 223 | } 224 | 225 | func clashTransport(network string, httpOpts clash_outbound.HTTPOptions, h2Opts clash_outbound.HTTP2Options, grpcOpts clash_outbound.GrpcOptions, wsOpts clash_outbound.WSOptions) *option.V2RayTransportOptions { 226 | switch network { 227 | case "http": 228 | var headers map[string]badoption.Listable[string] 229 | for key, values := range httpOpts.Headers { 230 | if headers == nil { 231 | headers = make(map[string]badoption.Listable[string]) 232 | } 233 | headers[key] = values 234 | } 235 | return &option.V2RayTransportOptions{ 236 | Type: C.V2RayTransportTypeHTTP, 237 | HTTPOptions: option.V2RayHTTPOptions{ 238 | Method: httpOpts.Method, 239 | Path: clashStringList(httpOpts.Path), 240 | Headers: headers, 241 | }, 242 | } 243 | case "h2": 244 | return &option.V2RayTransportOptions{ 245 | Type: C.V2RayTransportTypeHTTP, 246 | HTTPOptions: option.V2RayHTTPOptions{ 247 | Path: h2Opts.Path, 248 | Host: h2Opts.Host, 249 | }, 250 | } 251 | case "grpc": 252 | return &option.V2RayTransportOptions{ 253 | Type: C.V2RayTransportTypeGRPC, 254 | GRPCOptions: option.V2RayGRPCOptions{ 255 | ServiceName: grpcOpts.GrpcServiceName, 256 | }, 257 | } 258 | case "ws": 259 | var headers map[string]badoption.Listable[string] 260 | for key, value := range wsOpts.Headers { 261 | if headers == nil { 262 | headers = make(map[string]badoption.Listable[string]) 263 | } 264 | headers[key] = []string{value} 265 | } 266 | return &option.V2RayTransportOptions{ 267 | Type: C.V2RayTransportTypeWebsocket, 268 | WebsocketOptions: option.V2RayWebsocketOptions{ 269 | Path: wsOpts.Path, 270 | Headers: headers, 271 | MaxEarlyData: uint32(wsOpts.MaxEarlyData), 272 | EarlyDataHeaderName: wsOpts.EarlyDataHeaderName, 273 | }, 274 | } 275 | default: 276 | return nil 277 | } 278 | } 279 | 280 | func clashStringList(list []string) string { 281 | if len(list) > 0 { 282 | return list[0] 283 | } 284 | return "" 285 | } 286 | -------------------------------------------------------------------------------- /subscription/parser/link.go: -------------------------------------------------------------------------------- 1 | package parser 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/sagernet/sing-box/option" 7 | E "github.com/sagernet/sing/common/exceptions" 8 | ) 9 | 10 | func ParseSubscriptionLink(link string) (option.Outbound, error) { 11 | schemeIndex := strings.Index(link, "://") 12 | if schemeIndex == -1 { 13 | return option.Outbound{}, E.New("not a link") 14 | } 15 | scheme := link[:schemeIndex] 16 | switch scheme { 17 | case "ss": 18 | return ParseShadowsocksLink(link) 19 | default: 20 | return option.Outbound{}, E.New("unsupported scheme: ", scheme) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /subscription/parser/link_shadowsocks.go: -------------------------------------------------------------------------------- 1 | package parser 2 | 3 | import ( 4 | "net/url" 5 | "strconv" 6 | "strings" 7 | 8 | C "github.com/sagernet/sing-box/constant" 9 | "github.com/sagernet/sing-box/option" 10 | E "github.com/sagernet/sing/common/exceptions" 11 | ) 12 | 13 | func ParseShadowsocksLink(link string) (option.Outbound, error) { 14 | linkURL, err := url.Parse(link) 15 | if err != nil { 16 | return option.Outbound{}, err 17 | } 18 | 19 | if linkURL.User == nil { 20 | return option.Outbound{}, E.New("missing user info") 21 | } 22 | 23 | var options option.ShadowsocksOutboundOptions 24 | options.ServerOptions.Server = linkURL.Hostname() 25 | options.ServerOptions.ServerPort = portFromString(linkURL.Port()) 26 | if password, _ := linkURL.User.Password(); password != "" { 27 | options.Method = linkURL.User.Username() 28 | options.Password = password 29 | } else { 30 | userAndPassword, _ := decodeBase64URLSafe(linkURL.User.Username()) 31 | userAndPasswordParts := strings.Split(userAndPassword, ":") 32 | if len(userAndPasswordParts) != 2 { 33 | return option.Outbound{}, E.New("bad user info") 34 | } 35 | options.Method = userAndPasswordParts[0] 36 | options.Password = userAndPasswordParts[1] 37 | } 38 | 39 | plugin := linkURL.Query().Get("plugin") 40 | options.Plugin = shadowsocksPluginName(plugin) 41 | options.PluginOptions = shadowsocksPluginOptions(plugin) 42 | 43 | var outbound option.Outbound 44 | outbound.Type = C.TypeShadowsocks 45 | outbound.Tag = linkURL.Fragment 46 | outbound.Options = &options 47 | return outbound, nil 48 | } 49 | 50 | func portFromString(portString string) uint16 { 51 | port, _ := strconv.ParseUint(portString, 10, 16) 52 | return uint16(port) 53 | } 54 | 55 | func shadowsocksPluginName(plugin string) string { 56 | if index := strings.Index(plugin, ";"); index != -1 { 57 | return plugin[:index] 58 | } 59 | return plugin 60 | } 61 | 62 | func shadowsocksPluginOptions(plugin string) string { 63 | if index := strings.Index(plugin, ";"); index != -1 { 64 | return plugin[index+1:] 65 | } 66 | return "" 67 | } 68 | -------------------------------------------------------------------------------- /subscription/parser/parser.go: -------------------------------------------------------------------------------- 1 | package parser 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/sagernet/sing-box/option" 7 | E "github.com/sagernet/sing/common/exceptions" 8 | ) 9 | 10 | var subscriptionParsers = []func(ctx context.Context, content string) ([]option.Outbound, error){ 11 | ParseBoxSubscription, 12 | ParseClashSubscription, 13 | ParseSIP008Subscription, 14 | ParseRawSubscription, 15 | } 16 | 17 | func ParseSubscription(ctx context.Context, content string) ([]option.Outbound, error) { 18 | var pErr error 19 | for _, parser := range subscriptionParsers { 20 | servers, err := parser(ctx, content) 21 | if len(servers) > 0 { 22 | return servers, nil 23 | } 24 | pErr = E.Errors(pErr, err) 25 | } 26 | return nil, E.Cause(pErr, "no servers found") 27 | } 28 | -------------------------------------------------------------------------------- /subscription/parser/raw.go: -------------------------------------------------------------------------------- 1 | package parser 2 | 3 | import ( 4 | "context" 5 | "encoding/base64" 6 | "strings" 7 | 8 | "github.com/sagernet/sing-box/option" 9 | E "github.com/sagernet/sing/common/exceptions" 10 | ) 11 | 12 | func ParseRawSubscription(_ context.Context, content string) ([]option.Outbound, error) { 13 | if base64Content, err := decodeBase64URLSafe(content); err == nil { 14 | servers, _ := parseRawSubscription(base64Content) 15 | if len(servers) > 0 { 16 | return servers, err 17 | } 18 | } 19 | return parseRawSubscription(content) 20 | } 21 | 22 | func parseRawSubscription(content string) ([]option.Outbound, error) { 23 | var servers []option.Outbound 24 | content = strings.ReplaceAll(content, "\r\n", "\n") 25 | linkList := strings.Split(content, "\n") 26 | for _, linkLine := range linkList { 27 | if server, err := ParseSubscriptionLink(linkLine); err == nil { 28 | servers = append(servers, server) 29 | } 30 | } 31 | if len(servers) == 0 { 32 | return nil, E.New("no servers found") 33 | } 34 | return servers, nil 35 | } 36 | 37 | func decodeBase64URLSafe(content string) (string, error) { 38 | content = strings.ReplaceAll(content, " ", "-") 39 | content = strings.ReplaceAll(content, "/", "_") 40 | content = strings.ReplaceAll(content, "+", "-") 41 | content = strings.ReplaceAll(content, "=", "") 42 | result, err := base64.RawURLEncoding.DecodeString(content) 43 | return string(result), err 44 | } 45 | -------------------------------------------------------------------------------- /subscription/parser/sing_box.go: -------------------------------------------------------------------------------- 1 | package parser 2 | 3 | import ( 4 | "context" 5 | 6 | C "github.com/sagernet/sing-box/constant" 7 | "github.com/sagernet/sing-box/option" 8 | "github.com/sagernet/sing/common" 9 | E "github.com/sagernet/sing/common/exceptions" 10 | "github.com/sagernet/sing/common/json" 11 | ) 12 | 13 | func ParseBoxSubscription(ctx context.Context, content string) ([]option.Outbound, error) { 14 | options, err := json.UnmarshalExtendedContext[option.Options](ctx, []byte(content)) 15 | if err != nil { 16 | return nil, err 17 | } 18 | options.Outbounds = common.Filter(options.Outbounds, func(it option.Outbound) bool { 19 | switch it.Type { 20 | case C.TypeDirect, C.TypeBlock, C.TypeDNS, C.TypeSelector, C.TypeURLTest: 21 | return false 22 | default: 23 | return true 24 | } 25 | }) 26 | if len(options.Outbounds) == 0 { 27 | return nil, E.New("no servers found") 28 | } 29 | return options.Outbounds, nil 30 | } 31 | -------------------------------------------------------------------------------- /subscription/parser/sip008.go: -------------------------------------------------------------------------------- 1 | package parser 2 | 3 | import ( 4 | "context" 5 | 6 | C "github.com/sagernet/sing-box/constant" 7 | "github.com/sagernet/sing-box/option" 8 | E "github.com/sagernet/sing/common/exceptions" 9 | "github.com/sagernet/sing/common/json" 10 | ) 11 | 12 | type ShadowsocksDocument struct { 13 | Version int `json:"version"` 14 | Servers []ShadowsocksServerDocument `json:"servers"` 15 | } 16 | 17 | type ShadowsocksServerDocument struct { 18 | ID string `json:"id"` 19 | Remarks string `json:"remarks"` 20 | Server string `json:"server"` 21 | ServerPort int `json:"server_port"` 22 | Password string `json:"password"` 23 | Method string `json:"method"` 24 | Plugin string `json:"plugin"` 25 | PluginOpts string `json:"plugin_opts"` 26 | } 27 | 28 | func ParseSIP008Subscription(_ context.Context, content string) ([]option.Outbound, error) { 29 | var document ShadowsocksDocument 30 | err := json.Unmarshal([]byte(content), &document) 31 | if err != nil { 32 | return nil, E.Cause(err, "parse SIP008 document") 33 | } 34 | 35 | var servers []option.Outbound 36 | for _, server := range document.Servers { 37 | servers = append(servers, option.Outbound{ 38 | Type: C.TypeShadowsocks, 39 | Tag: server.Remarks, 40 | Options: &option.ShadowsocksOutboundOptions{ 41 | ServerOptions: option.ServerOptions{ 42 | Server: server.Server, 43 | ServerPort: uint16(server.ServerPort), 44 | }, 45 | Password: server.Password, 46 | Method: server.Method, 47 | Plugin: server.Plugin, 48 | PluginOptions: server.PluginOpts, 49 | }, 50 | }) 51 | } 52 | return servers, nil 53 | } 54 | -------------------------------------------------------------------------------- /subscription/process.go: -------------------------------------------------------------------------------- 1 | package subscription 2 | 3 | import ( 4 | "regexp" 5 | "strings" 6 | 7 | "github.com/sagernet/serenity/option" 8 | boxOption "github.com/sagernet/sing-box/option" 9 | "github.com/sagernet/sing/common" 10 | E "github.com/sagernet/sing/common/exceptions" 11 | ) 12 | 13 | type ProcessOptions struct { 14 | option.OutboundProcessOptions 15 | filter []*regexp.Regexp 16 | exclude []*regexp.Regexp 17 | rename []*Rename 18 | } 19 | 20 | type Rename struct { 21 | From *regexp.Regexp 22 | To string 23 | } 24 | 25 | func NewProcessOptions(options option.OutboundProcessOptions) (*ProcessOptions, error) { 26 | var ( 27 | filter []*regexp.Regexp 28 | exclude []*regexp.Regexp 29 | rename []*Rename 30 | ) 31 | for regexIndex, it := range options.Filter { 32 | regex, err := regexp.Compile(it) 33 | if err != nil { 34 | return nil, E.Cause(err, "parse filter[", regexIndex, "]") 35 | } 36 | filter = append(filter, regex) 37 | } 38 | for regexIndex, it := range options.Exclude { 39 | regex, err := regexp.Compile(it) 40 | if err != nil { 41 | return nil, E.Cause(err, "parse exclude[", regexIndex, "]") 42 | } 43 | exclude = append(exclude, regex) 44 | } 45 | if options.Rename != nil { 46 | for renameIndex, entry := range options.Rename.Entries() { 47 | regex, err := regexp.Compile(entry.Key) 48 | if err != nil { 49 | return nil, E.Cause(err, "parse rename[", renameIndex, "]: parse ", entry.Key) 50 | } 51 | rename = append(rename, &Rename{ 52 | From: regex, 53 | To: entry.Value, 54 | }) 55 | } 56 | } 57 | return &ProcessOptions{ 58 | OutboundProcessOptions: options, 59 | filter: filter, 60 | exclude: exclude, 61 | rename: rename, 62 | }, nil 63 | } 64 | 65 | func (o *ProcessOptions) Process(outbounds []boxOption.Outbound) []boxOption.Outbound { 66 | newOutbounds := make([]boxOption.Outbound, 0, len(outbounds)) 67 | renameResult := make(map[string]string) 68 | for _, outbound := range outbounds { 69 | var inProcess bool 70 | if len(o.filter) == 0 && len(o.FilterType) == 0 && len(o.exclude) == 0 && len(o.ExcludeType) == 0 { 71 | inProcess = true 72 | } else { 73 | if len(o.filter) > 0 { 74 | if common.Any(o.filter, func(it *regexp.Regexp) bool { 75 | return it.MatchString(outbound.Tag) 76 | }) { 77 | inProcess = true 78 | } 79 | } 80 | if !inProcess && len(o.FilterType) > 0 { 81 | if common.Contains(o.FilterType, outbound.Type) { 82 | inProcess = true 83 | } 84 | } 85 | if !inProcess && len(o.exclude) > 0 { 86 | if !common.Any(o.exclude, func(it *regexp.Regexp) bool { 87 | return it.MatchString(outbound.Tag) 88 | }) { 89 | inProcess = true 90 | } 91 | } 92 | if !inProcess && len(o.ExcludeType) > 0 { 93 | if !common.Contains(o.ExcludeType, outbound.Type) { 94 | inProcess = true 95 | } 96 | } 97 | } 98 | if o.Invert { 99 | inProcess = !inProcess 100 | } 101 | if !inProcess { 102 | newOutbounds = append(newOutbounds, outbound) 103 | continue 104 | } 105 | if o.Remove { 106 | continue 107 | } 108 | originTag := outbound.Tag 109 | if len(o.rename) > 0 { 110 | for _, rename := range o.rename { 111 | outbound.Tag = rename.From.ReplaceAllString(outbound.Tag, rename.To) 112 | } 113 | } 114 | if o.RemoveEmoji { 115 | outbound.Tag = removeEmojis(outbound.Tag) 116 | } 117 | outbound.Tag = strings.TrimSpace(outbound.Tag) 118 | if originTag != outbound.Tag { 119 | renameResult[originTag] = outbound.Tag 120 | } 121 | if o.RewriteMultiplex != nil { 122 | switch outboundOptions := outbound.Options.(type) { 123 | case *boxOption.ShadowsocksOutboundOptions: 124 | outboundOptions.Multiplex = o.RewriteMultiplex 125 | case *boxOption.TrojanOutboundOptions: 126 | outboundOptions.Multiplex = o.RewriteMultiplex 127 | case *boxOption.VMessOutboundOptions: 128 | outboundOptions.Multiplex = o.RewriteMultiplex 129 | case *boxOption.VLESSOutboundOptions: 130 | outboundOptions.Multiplex = o.RewriteMultiplex 131 | } 132 | } 133 | newOutbounds = append(newOutbounds, outbound) 134 | } 135 | if len(renameResult) > 0 { 136 | for i, outbound := range newOutbounds { 137 | if dialerOptionsWrapper, containsDialerOptions := outbound.Options.(boxOption.DialerOptionsWrapper); containsDialerOptions { 138 | dialerOptions := dialerOptionsWrapper.TakeDialerOptions() 139 | if dialerOptions.Detour == "" { 140 | continue 141 | } 142 | newTag, loaded := renameResult[dialerOptions.Detour] 143 | if !loaded { 144 | continue 145 | } 146 | dialerOptions.Detour = newTag 147 | dialerOptionsWrapper.ReplaceDialerOptions(dialerOptions) 148 | newOutbounds[i] = outbound 149 | } 150 | } 151 | } 152 | return newOutbounds 153 | } 154 | 155 | func removeEmojis(s string) string { 156 | var runes []rune 157 | for _, r := range s { 158 | if !(r >= 0x1F600 && r <= 0x1F64F || // Emoticons 159 | r >= 0x1F300 && r <= 0x1F5FF || // Symbols & Pictographs 160 | r >= 0x1F680 && r <= 0x1F6FF || // Transport & Map Symbols 161 | r >= 0x1F1E0 && r <= 0x1F1FF || // Flags 162 | r >= 0x2600 && r <= 0x26FF || // Misc symbols 163 | r >= 0x2700 && r <= 0x27BF || // Dingbats 164 | r >= 0xFE00 && r <= 0xFE0F || // Variation Selectors 165 | r >= 0x1F900 && r <= 0x1F9FF || // Supplemental Symbols and Pictographs 166 | r >= 0x1F018 && r <= 0x1F270 || // Various asian characters 167 | r >= 0x238C && r <= 0x2454 || // Misc items 168 | r >= 0x20D0 && r <= 0x20FF) { // Combining Diacritical Marks for Symbols 169 | runes = append(runes, r) 170 | } 171 | } 172 | return string(runes) 173 | } 174 | -------------------------------------------------------------------------------- /subscription/subscription.go: -------------------------------------------------------------------------------- 1 | package subscription 2 | 3 | import ( 4 | "context" 5 | "io" 6 | "net/http" 7 | "time" 8 | 9 | "github.com/sagernet/serenity/common/cachefile" 10 | C "github.com/sagernet/serenity/constant" 11 | "github.com/sagernet/serenity/option" 12 | "github.com/sagernet/serenity/subscription/parser" 13 | boxOption "github.com/sagernet/sing-box/option" 14 | E "github.com/sagernet/sing/common/exceptions" 15 | F "github.com/sagernet/sing/common/format" 16 | "github.com/sagernet/sing/common/logger" 17 | ) 18 | 19 | type Manager struct { 20 | ctx context.Context 21 | cancel context.CancelFunc 22 | logger logger.Logger 23 | cacheFile *cachefile.CacheFile 24 | subscriptions []*Subscription 25 | updateInterval time.Duration 26 | updateTicker *time.Ticker 27 | httpClient http.Client 28 | } 29 | 30 | type Subscription struct { 31 | option.Subscription 32 | rawServers []boxOption.Outbound 33 | processes []*ProcessOptions 34 | Servers []boxOption.Outbound 35 | LastUpdated time.Time 36 | LastEtag string 37 | } 38 | 39 | func NewSubscriptionManager(ctx context.Context, logger logger.Logger, cacheFile *cachefile.CacheFile, rawSubscriptions []option.Subscription) (*Manager, error) { 40 | var ( 41 | subscriptions []*Subscription 42 | interval time.Duration 43 | ) 44 | for index, subscription := range rawSubscriptions { 45 | if subscription.Name == "" { 46 | return nil, E.New("initialize subscription[", index, "]: missing name") 47 | } 48 | var processes []*ProcessOptions 49 | if interval == 0 || time.Duration(subscription.UpdateInterval) < interval { 50 | interval = time.Duration(subscription.UpdateInterval) 51 | } 52 | for processIndex, process := range subscription.Process { 53 | processOptions, err := NewProcessOptions(process) 54 | if err != nil { 55 | return nil, E.Cause(err, "initialize subscription[", subscription.Name, "]: parse process[", processIndex, "]") 56 | } 57 | processes = append(processes, processOptions) 58 | } 59 | subscriptions = append(subscriptions, &Subscription{ 60 | Subscription: subscription, 61 | processes: processes, 62 | }) 63 | } 64 | if interval == 0 { 65 | interval = option.DefaultSubscriptionUpdateInterval 66 | } 67 | ctx, cancel := context.WithCancel(ctx) 68 | return &Manager{ 69 | ctx: ctx, 70 | cancel: cancel, 71 | logger: logger, 72 | cacheFile: cacheFile, 73 | subscriptions: subscriptions, 74 | updateInterval: interval, 75 | }, nil 76 | } 77 | 78 | func (m *Manager) Start() error { 79 | for _, subscription := range m.subscriptions { 80 | savedSubscription := m.cacheFile.LoadSubscription(m.ctx, subscription.Name) 81 | if savedSubscription != nil { 82 | subscription.rawServers = savedSubscription.Content 83 | subscription.LastUpdated = savedSubscription.LastUpdated 84 | subscription.LastEtag = savedSubscription.LastEtag 85 | m.processSubscription(subscription, false) 86 | } 87 | } 88 | return nil 89 | } 90 | 91 | func (m *Manager) processSubscription(s *Subscription, onUpdate bool) { 92 | servers := s.rawServers 93 | for _, process := range s.processes { 94 | servers = process.Process(servers) 95 | } 96 | if s.DeDuplication { 97 | originLen := len(servers) 98 | servers = Deduplication(m.ctx, servers) 99 | if onUpdate && originLen != len(servers) { 100 | m.logger.Info("excluded ", originLen-len(servers), " duplicated servers in ", s.Name) 101 | } 102 | } 103 | s.Servers = servers 104 | } 105 | 106 | func (m *Manager) PostStart(headless bool) error { 107 | m.updateAll() 108 | if !headless { 109 | m.updateTicker = time.NewTicker(m.updateInterval) 110 | go m.loopUpdate() 111 | } 112 | return nil 113 | } 114 | 115 | func (m *Manager) Close() error { 116 | if m.updateTicker != nil { 117 | m.updateTicker.Stop() 118 | } 119 | m.cancel() 120 | m.httpClient.CloseIdleConnections() 121 | return nil 122 | } 123 | 124 | func (m *Manager) Subscriptions() []*Subscription { 125 | return m.subscriptions 126 | } 127 | 128 | func (m *Manager) loopUpdate() { 129 | for { 130 | select { 131 | case <-m.updateTicker.C: 132 | m.updateAll() 133 | case <-m.ctx.Done(): 134 | return 135 | } 136 | } 137 | } 138 | 139 | func (m *Manager) updateAll() { 140 | for _, subscription := range m.subscriptions { 141 | if time.Since(subscription.LastUpdated) < m.updateInterval { 142 | continue 143 | } 144 | err := m.update(subscription) 145 | if err != nil { 146 | m.logger.Error(E.Cause(err, "update subscription ", subscription.Name)) 147 | } 148 | } 149 | } 150 | 151 | func (m *Manager) update(subscription *Subscription) error { 152 | request, err := http.NewRequest("GET", subscription.URL, nil) 153 | if err != nil { 154 | return err 155 | } 156 | if subscription.UserAgent != "" { 157 | request.Header.Set("User-Agent", subscription.UserAgent) 158 | } else { 159 | request.Header.Set("User-Agent", F.ToString("serenity/", C.Version, " (sing-box ", C.CoreVersion(), "; Clash compatible)")) 160 | } 161 | if subscription.LastEtag != "" { 162 | request.Header.Set("If-None-Match", subscription.LastEtag) 163 | } 164 | response, err := m.httpClient.Do(request.WithContext(m.ctx)) 165 | if err != nil { 166 | return err 167 | } 168 | switch response.StatusCode { 169 | case http.StatusOK: 170 | case http.StatusNotModified: 171 | subscription.LastUpdated = time.Now() 172 | err = m.cacheFile.StoreSubscription(m.ctx, subscription.Name, &cachefile.Subscription{ 173 | Content: subscription.rawServers, 174 | LastUpdated: subscription.LastUpdated, 175 | LastEtag: subscription.LastEtag, 176 | }) 177 | if err != nil { 178 | return err 179 | } 180 | m.logger.Info("updated subscription ", subscription.Name, ": not modified") 181 | return nil 182 | default: 183 | return E.New("unexpected status: ", response.Status) 184 | } 185 | content, err := io.ReadAll(response.Body) 186 | if err != nil { 187 | response.Body.Close() 188 | return err 189 | } 190 | rawServers, err := parser.ParseSubscription(m.ctx, string(content)) 191 | if err != nil { 192 | response.Body.Close() 193 | return err 194 | } 195 | response.Body.Close() 196 | subscription.rawServers = rawServers 197 | m.processSubscription(subscription, true) 198 | eTagHeader := response.Header.Get("Etag") 199 | if eTagHeader != "" { 200 | subscription.LastEtag = eTagHeader 201 | } 202 | subscription.LastUpdated = time.Now() 203 | err = m.cacheFile.StoreSubscription(m.ctx, subscription.Name, &cachefile.Subscription{ 204 | Content: subscription.rawServers, 205 | LastUpdated: subscription.LastUpdated, 206 | LastEtag: subscription.LastEtag, 207 | }) 208 | if err != nil { 209 | return err 210 | } 211 | m.logger.Info("updated subscription ", subscription.Name, ": ", len(subscription.rawServers), " servers") 212 | return nil 213 | } 214 | -------------------------------------------------------------------------------- /template/filter/filter.go: -------------------------------------------------------------------------------- 1 | package filter 2 | 3 | import ( 4 | "github.com/sagernet/serenity/common/metadata" 5 | boxOption "github.com/sagernet/sing-box/option" 6 | ) 7 | 8 | type OptionsFilter func(metadata metadata.Metadata, options *boxOption.Options) error 9 | 10 | var filters []OptionsFilter 11 | 12 | func Filter(metadata metadata.Metadata, options *boxOption.Options) error { 13 | for _, filter := range filters { 14 | err := filter(metadata, options) 15 | if err != nil { 16 | return err 17 | } 18 | } 19 | return nil 20 | } 21 | -------------------------------------------------------------------------------- /template/filter/filter_1100.go: -------------------------------------------------------------------------------- 1 | package filter 2 | 3 | import ( 4 | "context" 5 | "net/netip" 6 | 7 | "github.com/sagernet/serenity/common/metadata" 8 | "github.com/sagernet/serenity/common/semver" 9 | C "github.com/sagernet/sing-box/constant" 10 | "github.com/sagernet/sing-box/option" 11 | "github.com/sagernet/sing/common" 12 | E "github.com/sagernet/sing/common/exceptions" 13 | "github.com/sagernet/sing/common/json" 14 | "github.com/sagernet/sing/common/json/badjson" 15 | ) 16 | 17 | func init() { 18 | filters = append(filters, filter1100) 19 | } 20 | 21 | func filter1100(metadata metadata.Metadata, options *option.Options) error { 22 | if metadata.Version == nil || metadata.Version.GreaterThanOrEqual(semver.ParseVersion("1.10.0-alpha.19")) { 23 | return nil 24 | } 25 | var newRuleSets []option.RuleSet 26 | var inlineRuleSets []option.RuleSet 27 | for _, ruleSet := range options.Route.RuleSet { 28 | if ruleSet.Type == C.RuleSetTypeInline { 29 | inlineRuleSets = append(inlineRuleSets, ruleSet) 30 | } else { 31 | newRuleSets = append(newRuleSets, ruleSet) 32 | } 33 | } 34 | options.Route.RuleSet = newRuleSets 35 | if len(inlineRuleSets) > 0 { 36 | var ( 37 | currentRules []option.Rule 38 | newRules []option.Rule 39 | ) 40 | currentRules = options.Route.Rules 41 | for _, inlineRuleSet := range inlineRuleSets { 42 | for i, rule := range currentRules { 43 | newRuleItems, err := expandInlineRule(inlineRuleSet, rule) 44 | if err != nil { 45 | return E.Cause(err, "expand rule[", i, "]") 46 | } 47 | newRules = append(newRules, newRuleItems...) 48 | } 49 | currentRules = newRules 50 | newRules = newRules[:0] 51 | } 52 | options.Route.Rules = currentRules 53 | 54 | var ( 55 | currentDNSRules []option.DNSRule 56 | newDNSRules []option.DNSRule 57 | ) 58 | currentDNSRules = options.DNS.Rules 59 | for _, inlineRuleSet := range inlineRuleSets { 60 | for i, rule := range currentDNSRules { 61 | newRuleItems, err := expandInlineDNSRule(inlineRuleSet, rule) 62 | if err != nil { 63 | return E.Cause(err, "expand dns rule[", i, "]") 64 | } 65 | newDNSRules = append(newDNSRules, newRuleItems...) 66 | } 67 | currentDNSRules = newDNSRules 68 | newDNSRules = newDNSRules[:0] 69 | } 70 | options.DNS.Rules = currentDNSRules 71 | } 72 | options.Route.Rules = common.Filter(options.Route.Rules, filter1100Rule) 73 | options.DNS.Rules = common.Filter(options.DNS.Rules, filter1100DNSRule) 74 | if metadata.Version.GreaterThanOrEqual(semver.ParseVersion("1.10.0-alpha.13")) { 75 | return nil 76 | } 77 | if len(options.Inbounds) > 0 { 78 | newInbounds := make([]option.Inbound, 0, len(options.Inbounds)) 79 | for _, inbound := range options.Inbounds { 80 | if inbound.Type == C.TypeTun { 81 | tunOptions := inbound.Options.(*option.TunInboundOptions) 82 | tunOptions.AutoRedirect = false 83 | tunOptions.RouteAddressSet = nil 84 | tunOptions.RouteExcludeAddressSet = nil 85 | //nolint:staticcheck 86 | //goland:noinspection GoDeprecation 87 | if len(tunOptions.Address) > 0 { 88 | tunOptions.Inet4Address = append(tunOptions.Inet4Address, common.Filter(tunOptions.Address, func(it netip.Prefix) bool { 89 | return it.Addr().Is4() 90 | })...) 91 | tunOptions.Inet6Address = append(tunOptions.Inet6Address, common.Filter(tunOptions.Address, func(it netip.Prefix) bool { 92 | return it.Addr().Is6() 93 | })...) 94 | tunOptions.Address = nil 95 | } 96 | //nolint:staticcheck 97 | //goland:noinspection GoDeprecation 98 | if len(tunOptions.RouteAddress) > 0 { 99 | tunOptions.Inet4RouteAddress = append(tunOptions.Inet4RouteAddress, common.Filter(tunOptions.RouteAddress, func(it netip.Prefix) bool { 100 | return it.Addr().Is4() 101 | })...) 102 | tunOptions.Inet6RouteAddress = append(tunOptions.Inet6RouteAddress, common.Filter(tunOptions.RouteAddress, func(it netip.Prefix) bool { 103 | return it.Addr().Is6() 104 | })...) 105 | tunOptions.RouteAddress = nil 106 | } 107 | //nolint:staticcheck 108 | //goland:noinspection GoDeprecation 109 | if len(tunOptions.RouteExcludeAddress) > 0 { 110 | tunOptions.Inet4RouteExcludeAddress = append(tunOptions.Inet4RouteExcludeAddress, common.Filter(tunOptions.RouteExcludeAddress, func(it netip.Prefix) bool { 111 | return it.Addr().Is4() 112 | })...) 113 | tunOptions.Inet6RouteExcludeAddress = append(tunOptions.Inet6RouteExcludeAddress, common.Filter(tunOptions.RouteExcludeAddress, func(it netip.Prefix) bool { 114 | return it.Addr().Is6() 115 | })...) 116 | tunOptions.RouteExcludeAddress = nil 117 | } 118 | } 119 | newInbounds = append(newInbounds, inbound) 120 | } 121 | options.Inbounds = newInbounds 122 | } 123 | return nil 124 | } 125 | 126 | func expandInlineRule(ruleSet option.RuleSet, rule option.Rule) ([]option.Rule, error) { 127 | var ( 128 | newRules []option.Rule 129 | err error 130 | ) 131 | if rule.Type == C.RuleTypeLogical { 132 | for i := range rule.LogicalOptions.Rules { 133 | newRules, err = expandInlineRule(ruleSet, rule.LogicalOptions.Rules[i]) 134 | if err != nil { 135 | return nil, E.Cause(err, "[", i, "]") 136 | } 137 | newRules = append(newRules, newRules...) 138 | } 139 | rule.LogicalOptions.Rules = newRules 140 | return []option.Rule{rule}, nil 141 | } 142 | if !common.Contains(rule.DefaultOptions.RuleSet, ruleSet.Tag) { 143 | return []option.Rule{rule}, nil 144 | } 145 | rule.DefaultOptions.RuleSet = common.Filter(rule.DefaultOptions.RuleSet, func(it string) bool { 146 | return it != ruleSet.Tag 147 | }) 148 | for i, hRule := range ruleSet.InlineOptions.Rules { 149 | var ( 150 | rawRule json.RawMessage 151 | newRule option.Rule 152 | ) 153 | rawRule, err = json.Marshal(hRule) 154 | if err != nil { 155 | return nil, E.Cause(err, "marshal inline rule ", ruleSet.Tag, "[", i, "]") 156 | } 157 | newRule, err = badjson.MergeFromSource(context.Background(), rawRule, rule, false) 158 | if err != nil { 159 | return nil, E.Cause(err, "merge inline rule ", ruleSet.Tag, "[", i, "]") 160 | } 161 | newRules = append(newRules, newRule) 162 | } 163 | return newRules, nil 164 | } 165 | 166 | func expandInlineDNSRule(ruleSet option.RuleSet, rule option.DNSRule) ([]option.DNSRule, error) { 167 | var ( 168 | newRules []option.DNSRule 169 | err error 170 | ) 171 | if rule.Type == C.RuleTypeLogical { 172 | for i := range rule.LogicalOptions.Rules { 173 | newRules, err = expandInlineDNSRule(ruleSet, rule.LogicalOptions.Rules[i]) 174 | if err != nil { 175 | return nil, E.Cause(err, "[", i, "]") 176 | } 177 | newRules = append(newRules, newRules...) 178 | } 179 | rule.LogicalOptions.Rules = newRules 180 | return []option.DNSRule{rule}, nil 181 | } 182 | if !common.Contains(rule.DefaultOptions.RuleSet, ruleSet.Tag) { 183 | return []option.DNSRule{rule}, nil 184 | } 185 | rule.DefaultOptions.RuleSet = common.Filter(rule.DefaultOptions.RuleSet, func(it string) bool { 186 | return it != ruleSet.Tag 187 | }) 188 | for i, hRule := range ruleSet.InlineOptions.Rules { 189 | var ( 190 | rawRule json.RawMessage 191 | newRule option.DNSRule 192 | ) 193 | rawRule, err = json.Marshal(hRule) 194 | if err != nil { 195 | return nil, E.Cause(err, "marshal inline rule ", ruleSet.Tag, "[", i, "]") 196 | } 197 | newRule, err = badjson.MergeFromSource(context.Background(), rawRule, rule, false) 198 | if err != nil { 199 | return nil, E.Cause(err, "merge inline rule ", ruleSet.Tag, "[", i, "]") 200 | } 201 | newRules = append(newRules, newRule) 202 | } 203 | return newRules, nil 204 | } 205 | 206 | func filter1100Rule(it option.Rule) bool { 207 | return !hasRule([]option.Rule{it}, func(it option.DefaultRule) bool { 208 | return it.RuleSetIPCIDRMatchSource 209 | }) 210 | } 211 | 212 | func filter1100DNSRule(it option.DNSRule) bool { 213 | return !hasDNSRule([]option.DNSRule{it}, func(it option.DefaultDNSRule) bool { 214 | return it.RuleSetIPCIDRMatchSource || it.RuleSetIPCIDRAcceptEmpty 215 | }) 216 | } 217 | -------------------------------------------------------------------------------- /template/filter/filter_190.go: -------------------------------------------------------------------------------- 1 | package filter 2 | 3 | import ( 4 | "github.com/sagernet/serenity/common/metadata" 5 | "github.com/sagernet/serenity/common/semver" 6 | "github.com/sagernet/sing-box/option" 7 | "github.com/sagernet/sing/common" 8 | ) 9 | 10 | func init() { 11 | filters = append(filters, filter190) 12 | } 13 | 14 | func filter190(metadata metadata.Metadata, options *option.Options) error { 15 | if metadata.Version == nil || metadata.Version.GreaterThanOrEqual(semver.ParseVersion("1.9.0-alpha.1")) { 16 | return nil 17 | } 18 | if options.DNS == nil || len(options.DNS.Rules) == 0 { 19 | return nil 20 | } 21 | options.DNS.Rules = common.Filter(options.DNS.Rules, filter190DNSRule) 22 | if metadata.Version == nil || metadata.Version.GreaterThanOrEqual(semver.ParseVersion("1.9.0-alpha.10")) { 23 | return nil 24 | } 25 | for _, inbound := range options.Inbounds { 26 | switch inboundOptions := inbound.Options.(type) { 27 | case *option.TunInboundOptions: 28 | if inboundOptions.Platform == nil || inboundOptions.Platform.HTTPProxy == nil { 29 | continue 30 | } 31 | httpProxy := inboundOptions.Platform.HTTPProxy 32 | if len(httpProxy.BypassDomain) > 0 || len(httpProxy.MatchDomain) > 0 { 33 | httpProxy.BypassDomain = nil 34 | httpProxy.MatchDomain = nil 35 | } 36 | } 37 | } 38 | return nil 39 | } 40 | 41 | func filter190DNSRule(it option.DNSRule) bool { 42 | return !hasDNSRule([]option.DNSRule{it}, func(it option.DefaultDNSRule) bool { 43 | return len(it.GeoIP) > 0 || len(it.IPCIDR) > 0 || it.IPIsPrivate 44 | }) 45 | } 46 | -------------------------------------------------------------------------------- /template/filter/filter_null_references.go: -------------------------------------------------------------------------------- 1 | package filter 2 | 3 | import ( 4 | M "github.com/sagernet/serenity/common/metadata" 5 | C "github.com/sagernet/sing-box/constant" 6 | "github.com/sagernet/sing-box/option" 7 | "github.com/sagernet/sing/common" 8 | ) 9 | 10 | func init() { 11 | filters = append(filters, filterNullGroupReference) 12 | } 13 | 14 | func filterNullGroupReference(metadata M.Metadata, options *option.Options) error { 15 | outboundTags := common.Map(options.Outbounds, func(it option.Outbound) string { 16 | return it.Tag 17 | }) 18 | for _, outbound := range options.Outbounds { 19 | switch outboundOptions := outbound.Options.(type) { 20 | case *option.SelectorOutboundOptions: 21 | outboundOptions.Outbounds = common.Filter(outboundOptions.Outbounds, func(outbound string) bool { 22 | return common.Contains(outboundTags, outbound) 23 | }) 24 | case *option.URLTestOutboundOptions: 25 | outboundOptions.Outbounds = common.Filter(outboundOptions.Outbounds, func(outbound string) bool { 26 | return common.Contains(outboundTags, outbound) 27 | }) 28 | default: 29 | continue 30 | } 31 | } 32 | options.Route.Rules = common.Filter(options.Route.Rules, func(it option.Rule) bool { 33 | switch it.Type { 34 | case C.RuleTypeDefault: 35 | if it.DefaultOptions.Action != C.RuleActionTypeRoute { 36 | return true 37 | } 38 | return common.Contains(outboundTags, it.DefaultOptions.RouteOptions.Outbound) 39 | case C.RuleTypeLogical: 40 | if it.LogicalOptions.Action != C.RuleActionTypeRoute { 41 | return true 42 | } 43 | return common.Contains(outboundTags, it.LogicalOptions.RouteOptions.Outbound) 44 | default: 45 | panic("no") 46 | } 47 | }) 48 | return nil 49 | } 50 | -------------------------------------------------------------------------------- /template/filter/filter_rule.go: -------------------------------------------------------------------------------- 1 | package filter 2 | 3 | import ( 4 | C "github.com/sagernet/sing-box/constant" 5 | "github.com/sagernet/sing-box/option" 6 | ) 7 | 8 | func hasRule(rules []option.Rule, cond func(rule option.DefaultRule) bool) bool { 9 | for _, rule := range rules { 10 | switch rule.Type { 11 | case C.RuleTypeDefault: 12 | if cond(rule.DefaultOptions) { 13 | return true 14 | } 15 | case C.RuleTypeLogical: 16 | if hasRule(rule.LogicalOptions.Rules, cond) { 17 | return true 18 | } 19 | } 20 | } 21 | return false 22 | } 23 | 24 | func hasDNSRule(rules []option.DNSRule, cond func(rule option.DefaultDNSRule) bool) bool { 25 | for _, rule := range rules { 26 | switch rule.Type { 27 | case C.RuleTypeDefault: 28 | if cond(rule.DefaultOptions) { 29 | return true 30 | } 31 | case C.RuleTypeLogical: 32 | if hasDNSRule(rule.LogicalOptions.Rules, cond) { 33 | return true 34 | } 35 | } 36 | } 37 | return false 38 | } 39 | 40 | func isWIFIRule(rule option.DefaultRule) bool { 41 | return len(rule.WIFISSID) > 0 || len(rule.WIFIBSSID) > 0 42 | } 43 | 44 | func isWIFIDNSRule(rule option.DefaultDNSRule) bool { 45 | return len(rule.WIFISSID) > 0 || len(rule.WIFIBSSID) > 0 46 | } 47 | 48 | func isRuleSetRule(rule option.DefaultRule) bool { 49 | return len(rule.RuleSet) > 0 50 | } 51 | 52 | func isRuleSetDNSRule(rule option.DefaultDNSRule) bool { 53 | return len(rule.RuleSet) > 0 54 | } 55 | 56 | func isIPIsPrivateRule(rule option.DefaultRule) bool { 57 | return rule.IPIsPrivate 58 | } 59 | -------------------------------------------------------------------------------- /template/filter/filter_test.go: -------------------------------------------------------------------------------- 1 | package filter 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/sagernet/serenity/common/metadata" 7 | "github.com/sagernet/serenity/common/semver" 8 | C "github.com/sagernet/sing-box/constant" 9 | "github.com/sagernet/sing-box/option" 10 | 11 | "github.com/stretchr/testify/require" 12 | ) 13 | 14 | func TestFilter1100(t *testing.T) { 15 | t.Parallel() 16 | options := &option.Options{ 17 | DNS: &option.DNSOptions{ 18 | Rules: []option.DNSRule{ 19 | { 20 | Type: C.RuleTypeDefault, 21 | DefaultOptions: option.DefaultDNSRule{ 22 | RawDefaultDNSRule: option.RawDefaultDNSRule{ 23 | RuleSet: []string{"test"}, 24 | }, 25 | DNSRuleAction: option.DNSRuleAction{ 26 | Action: C.RuleActionTypeRoute, 27 | RouteOptions: option.DNSRouteActionOptions{ 28 | Server: "test", 29 | }, 30 | }, 31 | }, 32 | }, 33 | }, 34 | }, 35 | Route: &option.RouteOptions{ 36 | Rules: []option.Rule{ 37 | { 38 | Type: C.RuleTypeDefault, 39 | DefaultOptions: option.DefaultRule{ 40 | RawDefaultRule: option.RawDefaultRule{ 41 | Domain: []string{"example.com"}, 42 | }, 43 | RuleAction: option.RuleAction{ 44 | Action: C.RuleActionTypeRoute, 45 | RouteOptions: option.RouteActionOptions{ 46 | Outbound: "test", 47 | }, 48 | }, 49 | }, 50 | }, 51 | }, 52 | RuleSet: []option.RuleSet{ 53 | { 54 | Type: C.RuleSetTypeInline, 55 | Tag: "test", 56 | InlineOptions: option.PlainRuleSet{ 57 | Rules: []option.HeadlessRule{ 58 | { 59 | Type: C.RuleTypeDefault, 60 | DefaultOptions: option.DefaultHeadlessRule{ 61 | Domain: []string{"example.com"}, 62 | }, 63 | }, 64 | }, 65 | }, 66 | }, 67 | }, 68 | }, 69 | } 70 | err := filter1100(metadata.Metadata{Version: &semver.Version{Major: 1, Minor: 9, Patch: 3}}, options) 71 | require.NoError(t, err) 72 | require.Equal(t, options, &option.Options{ 73 | DNS: &option.DNSOptions{ 74 | Rules: []option.DNSRule{ 75 | { 76 | Type: C.RuleTypeDefault, 77 | DefaultOptions: option.DefaultDNSRule{ 78 | RawDefaultDNSRule: option.RawDefaultDNSRule{ 79 | Domain: []string{"example.com"}, 80 | }, 81 | DNSRuleAction: option.DNSRuleAction{ 82 | Action: C.RuleActionTypeRoute, 83 | RouteOptions: option.DNSRouteActionOptions{ 84 | Server: "test", 85 | }, 86 | }, 87 | }, 88 | }, 89 | }, 90 | }, 91 | Route: &option.RouteOptions{ 92 | Rules: []option.Rule{ 93 | { 94 | Type: C.RuleTypeDefault, 95 | DefaultOptions: option.DefaultRule{ 96 | RawDefaultRule: option.RawDefaultRule{ 97 | Domain: []string{"example.com"}, 98 | }, 99 | RuleAction: option.RuleAction{ 100 | Action: C.RuleActionTypeRoute, 101 | RouteOptions: option.RouteActionOptions{ 102 | Outbound: "test", 103 | }, 104 | }, 105 | }, 106 | }, 107 | }, 108 | }, 109 | }) 110 | } 111 | -------------------------------------------------------------------------------- /template/manager.go: -------------------------------------------------------------------------------- 1 | package template 2 | 3 | import ( 4 | "context" 5 | "regexp" 6 | 7 | "github.com/sagernet/serenity/option" 8 | C "github.com/sagernet/sing-box/constant" 9 | E "github.com/sagernet/sing/common/exceptions" 10 | "github.com/sagernet/sing/common/json" 11 | "github.com/sagernet/sing/common/json/badjson" 12 | "github.com/sagernet/sing/common/logger" 13 | ) 14 | 15 | type Manager struct { 16 | ctx context.Context 17 | logger logger.Logger 18 | templates []*Template 19 | } 20 | 21 | func extendTemplate(ctx context.Context, rawTemplates []option.Template, root, current option.Template) (option.Template, error) { 22 | if current.Extend == "" { 23 | return current, nil 24 | } else if root.Name == current.Extend { 25 | return option.Template{}, E.New("initialize template[", current.Name, "]: circular extend detected: ", current.Extend) 26 | } 27 | var next option.Template 28 | for _, it := range rawTemplates { 29 | if it.Name == current.Extend { 30 | next = it 31 | break 32 | } 33 | } 34 | if next.Name == "" { 35 | return option.Template{}, E.New("initialize template[", current.Name, "]: extended template not found: ", current.Extend) 36 | } 37 | if next.Extend != "" { 38 | newNext, err := extendTemplate(ctx, rawTemplates, root, next) 39 | if err != nil { 40 | return option.Template{}, E.Cause(err, next.Extend) 41 | } 42 | next = newNext 43 | } 44 | newRawTemplate, err := badjson.MergeJSON(ctx, next.RawMessage, current.RawMessage, false) 45 | if err != nil { 46 | return option.Template{}, E.Cause(err, "initialize template[", current.Name, "]: merge extended template: ", current.Extend) 47 | } 48 | newTemplate, err := json.UnmarshalExtendedContext[option.Template](ctx, newRawTemplate) 49 | if err != nil { 50 | return option.Template{}, E.Cause(err, "initialize template[", current.Name, "]: unmarshal extended template: ", current.Extend) 51 | } 52 | newTemplate.RawMessage = newRawTemplate 53 | return newTemplate, nil 54 | } 55 | 56 | func NewManager(ctx context.Context, logger logger.Logger, rawTemplates []option.Template) (*Manager, error) { 57 | var templates []*Template 58 | for templateIndex, template := range rawTemplates { 59 | if template.Name == "" { 60 | return nil, E.New("initialize template[", templateIndex, "]: missing name") 61 | } 62 | if template.Extend != "" { 63 | newTemplate, err := extendTemplate(ctx, rawTemplates, template, template) 64 | if err != nil { 65 | return nil, err 66 | } 67 | template = newTemplate 68 | } 69 | var groups []*ExtraGroup 70 | for groupIndex, group := range template.ExtraGroups { 71 | if group.Tag == "" { 72 | return nil, E.New("initialize template[", template.Name, "]: extra_group[", groupIndex, "]: missing tag") 73 | } 74 | switch group.Type { 75 | case C.TypeSelector, C.TypeURLTest: 76 | case "": 77 | return nil, E.New("initialize template[", template.Name, "]: extra_group[", group.Tag, "]: missing type") 78 | default: 79 | return nil, E.New("initialize template[", template.Name, "]: extra_group[", group.Tag, "]: invalid group type: ", group.Type) 80 | } 81 | var ( 82 | filter []*regexp.Regexp 83 | exclude []*regexp.Regexp 84 | ) 85 | for filterIndex, it := range group.Filter { 86 | regex, err := regexp.Compile(it) 87 | if err != nil { 88 | return nil, E.Cause(err, "initialize template[", template.Name, "]: parse extra_group[", group.Tag, "]: parse filter[", filterIndex, "]: ", it) 89 | } 90 | filter = append(filter, regex) 91 | } 92 | for excludeIndex, it := range group.Exclude { 93 | regex, err := regexp.Compile(it) 94 | if err != nil { 95 | return nil, E.Cause(err, "initialize template[", template.Name, "]: parse extra_group[", group.Tag, "]: parse exclude[", excludeIndex, "]: ", it) 96 | } 97 | exclude = append(exclude, regex) 98 | } 99 | groups = append(groups, &ExtraGroup{ 100 | ExtraGroup: group, 101 | filter: filter, 102 | exclude: exclude, 103 | }) 104 | } 105 | templates = append(templates, &Template{ 106 | Template: template, 107 | groups: groups, 108 | }) 109 | } 110 | return &Manager{ 111 | ctx: ctx, 112 | logger: logger, 113 | templates: templates, 114 | }, nil 115 | } 116 | 117 | func (m *Manager) TemplateByName(name string) *Template { 118 | for _, template := range m.templates { 119 | if template.Name == name { 120 | return template 121 | } 122 | } 123 | return nil 124 | } 125 | -------------------------------------------------------------------------------- /template/render_dns.go: -------------------------------------------------------------------------------- 1 | package template 2 | 3 | import ( 4 | "context" 5 | "net/netip" 6 | "net/url" 7 | 8 | M "github.com/sagernet/serenity/common/metadata" 9 | "github.com/sagernet/serenity/common/semver" 10 | C "github.com/sagernet/sing-box/constant" 11 | "github.com/sagernet/sing-box/option" 12 | "github.com/sagernet/sing-dns" 13 | "github.com/sagernet/sing/common" 14 | "github.com/sagernet/sing/common/json/badoption" 15 | BM "github.com/sagernet/sing/common/metadata" 16 | 17 | mDNS "github.com/miekg/dns" 18 | ) 19 | 20 | func (t *Template) renderDNS(ctx context.Context, metadata M.Metadata, options *option.Options) error { 21 | var ( 22 | domainStrategy option.DomainStrategy 23 | domainStrategyLocal option.DomainStrategy 24 | ) 25 | if t.DomainStrategy != option.DomainStrategy(dns.DomainStrategyAsIS) { 26 | domainStrategy = t.DomainStrategy 27 | } else if t.EnableFakeIP { 28 | domainStrategy = option.DomainStrategy(dns.DomainStrategyPreferIPv4) 29 | } else { 30 | domainStrategy = option.DomainStrategy(dns.DomainStrategyUseIPv4) 31 | } 32 | if t.DomainStrategyLocal != option.DomainStrategy(dns.DomainStrategyAsIS) { 33 | domainStrategyLocal = t.DomainStrategyLocal 34 | } else { 35 | domainStrategyLocal = option.DomainStrategy(dns.DomainStrategyPreferIPv4) 36 | } 37 | if domainStrategyLocal == domainStrategy { 38 | domainStrategyLocal = 0 39 | } 40 | options.DNS = &option.DNSOptions{ 41 | RawDNSOptions: option.RawDNSOptions{ 42 | ReverseMapping: !t.DisableTrafficBypass && metadata.Platform != M.PlatformUnknown && !metadata.Platform.IsApple(), 43 | DNSClientOptions: option.DNSClientOptions{ 44 | Strategy: domainStrategy, 45 | IndependentCache: t.EnableFakeIP, 46 | }, 47 | }, 48 | } 49 | dnsDefault := t.DNS 50 | if dnsDefault == "" { 51 | dnsDefault = DefaultDNS 52 | } 53 | dnsLocal := t.DNSLocal 54 | if dnsLocal == "" { 55 | dnsLocal = DefaultDNSLocal 56 | } 57 | directTag := t.DirectTag 58 | if directTag == "" { 59 | directTag = DefaultDirectTag 60 | } 61 | defaultTag := t.DefaultTag 62 | if defaultTag == "" { 63 | defaultTag = DefaultDefaultTag 64 | } 65 | newDNSServers := metadata.Version != nil && metadata.Version.GreaterThanOrEqual(semver.ParseVersion("1.12.0-alpha.1")) 66 | defaultDNSOptions := option.DNSServerOptions{ 67 | Tag: DNSDefaultTag, 68 | Options: &option.LegacyDNSServerOptions{ 69 | Address: dnsDefault, 70 | }, 71 | } 72 | if dnsDefaultUrl, err := url.Parse(dnsDefault); err == nil && BM.IsDomainName(dnsDefaultUrl.Hostname()) { 73 | defaultDNSOptions.Options.(*option.LegacyDNSServerOptions).AddressResolver = DNSLocalTag 74 | } 75 | if newDNSServers { 76 | defaultDNSOptions.Options.(*option.LegacyDNSServerOptions).Detour = defaultTag 77 | defaultDNSOptions.Upgrade(ctx) 78 | } 79 | options.DNS.Servers = append(options.DNS.Servers, defaultDNSOptions) 80 | var ( 81 | localDNSOptions option.DNSServerOptions 82 | localDNSIsDomain bool 83 | ) 84 | if t.DisableTrafficBypass { 85 | localDNSOptions = option.DNSServerOptions{ 86 | Tag: DNSLocalTag, 87 | Options: &option.LegacyDNSServerOptions{ 88 | Address: "local", 89 | Strategy: domainStrategyLocal, 90 | }, 91 | } 92 | } else { 93 | localDNSOptions = option.DNSServerOptions{ 94 | Tag: DNSLocalTag, 95 | Options: &option.LegacyDNSServerOptions{ 96 | Address: dnsLocal, 97 | Detour: directTag, 98 | Strategy: domainStrategyLocal, 99 | }, 100 | } 101 | if dnsLocalUrl, err := url.Parse(dnsLocal); err == nil && BM.IsDomainName(dnsLocalUrl.Hostname()) { 102 | defaultDNSOptions.Options.(*option.LegacyDNSServerOptions).AddressResolver = DNSLocalSetupTag 103 | localDNSIsDomain = true 104 | } 105 | } 106 | if newDNSServers { 107 | localDNSOptions.Options.(*option.LegacyDNSServerOptions).Detour = "" 108 | localDNSOptions.Upgrade(ctx) 109 | } 110 | options.DNS.Servers = append(options.DNS.Servers, localDNSOptions) 111 | if localDNSIsDomain { 112 | if newDNSServers { 113 | options.DNS.Servers = append(options.DNS.Servers, option.DNSServerOptions{ 114 | Tag: DNSLocalSetupTag, 115 | Options: &option.LocalDNSServerOptions{}, 116 | }) 117 | } else { 118 | options.DNS.Servers = append(options.DNS.Servers, option.DNSServerOptions{ 119 | Tag: DNSLocalSetupTag, 120 | Options: &option.LegacyDNSServerOptions{ 121 | Address: "local", 122 | Strategy: domainStrategyLocal, 123 | }, 124 | }) 125 | } 126 | } 127 | if t.EnableFakeIP { 128 | if newDNSServers { 129 | var inet4Range, inet6Range *badoption.Prefix 130 | if t.CustomFakeIP != nil { 131 | inet4Range = t.CustomFakeIP.Inet4Range 132 | inet6Range = t.CustomFakeIP.Inet6Range 133 | } else { 134 | inet4Range = (*badoption.Prefix)(common.Ptr(netip.MustParsePrefix("198.18.0.0/15"))) 135 | inet6Range = (*badoption.Prefix)(common.Ptr(netip.MustParsePrefix("fc00::/18"))) 136 | } 137 | options.DNS.Servers = append(options.DNS.Servers, option.DNSServerOptions{ 138 | Tag: DNSFakeIPTag, 139 | Options: &option.FakeIPDNSServerOptions{ 140 | Inet4Range: inet4Range, 141 | Inet6Range: inet6Range, 142 | }, 143 | }) 144 | } else { 145 | if t.CustomFakeIP != nil { 146 | options.DNS.FakeIP = &option.LegacyDNSFakeIPOptions{ 147 | Inet4Range: t.CustomFakeIP.Inet4Range, 148 | Inet6Range: t.CustomFakeIP.Inet6Range, 149 | } 150 | } else if options.DNS.FakeIP == nil { 151 | options.DNS.FakeIP = &option.LegacyDNSFakeIPOptions{} 152 | } 153 | options.DNS.FakeIP.Enabled = true 154 | if options.DNS.FakeIP.Inet4Range == nil || !options.DNS.FakeIP.Inet4Range.Build(netip.Prefix{}).IsValid() { 155 | options.DNS.FakeIP.Inet4Range = (*badoption.Prefix)(common.Ptr(netip.MustParsePrefix("198.18.0.0/15"))) 156 | } 157 | if !t.DisableIPv6() && options.DNS.FakeIP.Inet6Range == nil || !options.DNS.FakeIP.Inet6Range.Build(netip.Prefix{}).IsValid() { 158 | options.DNS.FakeIP.Inet6Range = (*badoption.Prefix)(common.Ptr(netip.MustParsePrefix("fc00::/18"))) 159 | } 160 | options.DNS.Servers = append(options.DNS.Servers, option.DNSServerOptions{ 161 | Tag: DNSFakeIPTag, 162 | Options: &option.LegacyDNSServerOptions{ 163 | Address: "fakeip", 164 | }, 165 | }) 166 | } 167 | } 168 | options.DNS.Servers = append(options.DNS.Servers, t.DNSServers...) 169 | if !newDNSServers { 170 | options.DNS.Rules = []option.DNSRule{ 171 | { 172 | Type: C.RuleTypeDefault, 173 | DefaultOptions: option.DefaultDNSRule{ 174 | RawDefaultDNSRule: option.RawDefaultDNSRule{ 175 | Outbound: []string{"any"}, 176 | }, 177 | DNSRuleAction: option.DNSRuleAction{ 178 | Action: C.RuleActionTypeRoute, 179 | RouteOptions: option.DNSRouteActionOptions{ 180 | Server: DNSLocalTag, 181 | }, 182 | }, 183 | }, 184 | }, 185 | } 186 | } 187 | clashModeRule := t.ClashModeRule 188 | if clashModeRule == "" { 189 | clashModeRule = "Rule" 190 | } 191 | clashModeGlobal := t.ClashModeGlobal 192 | if clashModeGlobal == "" { 193 | clashModeGlobal = "Global" 194 | } 195 | clashModeDirect := t.ClashModeDirect 196 | if clashModeDirect == "" { 197 | clashModeDirect = "Direct" 198 | } 199 | 200 | if !t.DisableClashMode { 201 | options.DNS.Rules = append(options.DNS.Rules, option.DNSRule{ 202 | Type: C.RuleTypeDefault, 203 | DefaultOptions: option.DefaultDNSRule{ 204 | RawDefaultDNSRule: option.RawDefaultDNSRule{ 205 | ClashMode: clashModeGlobal, 206 | }, 207 | DNSRuleAction: option.DNSRuleAction{ 208 | Action: C.RuleActionTypeRoute, 209 | RouteOptions: option.DNSRouteActionOptions{ 210 | Server: DNSDefaultTag, 211 | }, 212 | }, 213 | }, 214 | }, option.DNSRule{ 215 | Type: C.RuleTypeDefault, 216 | DefaultOptions: option.DefaultDNSRule{ 217 | RawDefaultDNSRule: option.RawDefaultDNSRule{ 218 | ClashMode: clashModeDirect, 219 | }, 220 | DNSRuleAction: option.DNSRuleAction{ 221 | Action: C.RuleActionTypeRoute, 222 | RouteOptions: option.DNSRouteActionOptions{ 223 | Server: DNSLocalTag, 224 | }, 225 | }, 226 | }, 227 | }) 228 | } 229 | options.DNS.Rules = append(options.DNS.Rules, t.PreDNSRules...) 230 | if len(t.CustomDNSRules) == 0 { 231 | if !t.DisableTrafficBypass { 232 | options.DNS.Rules = append(options.DNS.Rules, option.DNSRule{ 233 | Type: C.RuleTypeDefault, 234 | DefaultOptions: option.DefaultDNSRule{ 235 | RawDefaultDNSRule: option.RawDefaultDNSRule{ 236 | RuleSet: []string{"geosite-geolocation-cn"}, 237 | }, 238 | DNSRuleAction: option.DNSRuleAction{ 239 | Action: C.RuleActionTypeRoute, 240 | RouteOptions: option.DNSRouteActionOptions{ 241 | Server: DNSLocalTag, 242 | }, 243 | }, 244 | }, 245 | }) 246 | if !t.DisableDNSLeak && (metadata.Version == nil || metadata.Version.GreaterThanOrEqual(semver.ParseVersion("1.9.0-alpha.1"))) { 247 | options.DNS.Rules = append(options.DNS.Rules, option.DNSRule{ 248 | Type: C.RuleTypeDefault, 249 | DefaultOptions: option.DefaultDNSRule{ 250 | RawDefaultDNSRule: option.RawDefaultDNSRule{ 251 | ClashMode: clashModeRule, 252 | }, 253 | DNSRuleAction: option.DNSRuleAction{ 254 | Action: C.RuleActionTypeRoute, 255 | RouteOptions: option.DNSRouteActionOptions{ 256 | Server: DNSDefaultTag, 257 | }, 258 | }, 259 | }, 260 | }, option.DNSRule{ 261 | Type: C.RuleTypeLogical, 262 | LogicalOptions: option.LogicalDNSRule{ 263 | RawLogicalDNSRule: option.RawLogicalDNSRule{ 264 | Mode: C.LogicalTypeAnd, 265 | Rules: []option.DNSRule{ 266 | { 267 | Type: C.RuleTypeDefault, 268 | DefaultOptions: option.DefaultDNSRule{ 269 | RawDefaultDNSRule: option.RawDefaultDNSRule{ 270 | RuleSet: []string{"geoip-cn"}, 271 | }, 272 | }, 273 | }, 274 | { 275 | Type: C.RuleTypeDefault, 276 | DefaultOptions: option.DefaultDNSRule{ 277 | RawDefaultDNSRule: option.RawDefaultDNSRule{ 278 | RuleSet: []string{"geosite-geolocation-!cn"}, 279 | Invert: true, 280 | }, 281 | }, 282 | }, 283 | }, 284 | }, 285 | DNSRuleAction: option.DNSRuleAction{ 286 | Action: C.RuleActionTypeRoute, 287 | RouteOptions: option.DNSRouteActionOptions{ 288 | Server: DNSLocalTag, 289 | }, 290 | }, 291 | }, 292 | }) 293 | } 294 | } 295 | } else { 296 | options.DNS.Rules = append(options.DNS.Rules, t.CustomDNSRules...) 297 | } 298 | if t.EnableFakeIP { 299 | options.DNS.Rules = append(options.DNS.Rules, option.DNSRule{ 300 | Type: C.RuleTypeDefault, 301 | DefaultOptions: option.DefaultDNSRule{ 302 | RawDefaultDNSRule: option.RawDefaultDNSRule{ 303 | QueryType: []option.DNSQueryType{ 304 | option.DNSQueryType(mDNS.TypeA), 305 | option.DNSQueryType(mDNS.TypeAAAA), 306 | }, 307 | }, 308 | DNSRuleAction: option.DNSRuleAction{ 309 | Action: C.RuleActionTypeRoute, 310 | RouteOptions: option.DNSRouteActionOptions{ 311 | Server: DNSFakeIPTag, 312 | }, 313 | }, 314 | }, 315 | }) 316 | } 317 | return nil 318 | } 319 | -------------------------------------------------------------------------------- /template/render_experimental.go: -------------------------------------------------------------------------------- 1 | package template 2 | 3 | import ( 4 | "context" 5 | 6 | M "github.com/sagernet/serenity/common/metadata" 7 | "github.com/sagernet/serenity/common/semver" 8 | "github.com/sagernet/sing-box/option" 9 | "github.com/sagernet/sing/common" 10 | "github.com/sagernet/sing/common/json/badjson" 11 | ) 12 | 13 | func (t *Template) renderExperimental(ctx context.Context, metadata M.Metadata, options *option.Options, profileName string) error { 14 | if t.DisableCacheFile && t.DisableClashMode && t.CustomClashAPI == nil { 15 | return nil 16 | } 17 | options.Experimental = &option.ExperimentalOptions{} 18 | disable18Features := metadata.Version != nil && metadata.Version.LessThan(semver.ParseVersion("1.8.0-alpha.10")) 19 | if !t.DisableCacheFile { 20 | if disable18Features { 21 | //nolint:staticcheck 22 | //goland:noinspection GoDeprecation 23 | options.Experimental.ClashAPI = &option.ClashAPIOptions{ 24 | CacheID: profileName, 25 | StoreMode: true, 26 | StoreSelected: true, 27 | StoreFakeIP: t.EnableFakeIP, 28 | } 29 | } else { 30 | options.Experimental.CacheFile = &option.CacheFileOptions{ 31 | Enabled: true, 32 | CacheID: profileName, 33 | StoreFakeIP: t.EnableFakeIP, 34 | } 35 | if !t.DisableDNSLeak && (metadata.Version == nil || metadata.Version.GreaterThanOrEqual(semver.ParseVersion("1.9.0-alpha.1"))) { 36 | options.Experimental.CacheFile.StoreRDRC = true 37 | } 38 | } 39 | } 40 | 41 | if t.CustomClashAPI != nil { 42 | newClashOptions, err := badjson.MergeFromDestination(ctx, options.Experimental.ClashAPI, t.CustomClashAPI.Message, true) 43 | if err != nil { 44 | return err 45 | } 46 | options.Experimental.ClashAPI = newClashOptions 47 | } else if options.Experimental.ClashAPI == nil { 48 | options.Experimental.ClashAPI = &option.ClashAPIOptions{} 49 | } 50 | 51 | if !t.DisableExternalController && options.Experimental.ClashAPI.ExternalController == "" { 52 | options.Experimental.ClashAPI.ExternalController = "127.0.0.1:9090" 53 | } 54 | 55 | if !t.DisableClashMode { 56 | if !t.DisableDNSLeak && (metadata.Version == nil || metadata.Version.GreaterThanOrEqual(semver.ParseVersion("1.9.0-alpha.1"))) { 57 | clashModeLeak := t.ClashModeLeak 58 | if clashModeLeak == "" { 59 | clashModeLeak = "Leak" 60 | } 61 | options.Experimental.ClashAPI.DefaultMode = clashModeLeak 62 | } else { 63 | options.Experimental.ClashAPI.DefaultMode = t.ClashModeRule 64 | } 65 | } 66 | if t.PProfListen != "" { 67 | if options.Experimental.Debug == nil { 68 | options.Experimental.Debug = &option.DebugOptions{} 69 | } 70 | options.Experimental.Debug.Listen = t.PProfListen 71 | } 72 | if t.MemoryLimit.Value() > 0 && !metadata.Platform.IsNetworkExtensionMemoryLimited() { 73 | if options.Experimental.Debug == nil { 74 | options.Experimental.Debug = &option.DebugOptions{} 75 | } 76 | options.Experimental.Debug.MemoryLimit = t.MemoryLimit 77 | options.Experimental.Debug.OOMKiller = common.Ptr(true) 78 | } 79 | return nil 80 | } 81 | -------------------------------------------------------------------------------- /template/render_geo_resources.go: -------------------------------------------------------------------------------- 1 | package template 2 | 3 | import ( 4 | M "github.com/sagernet/serenity/common/metadata" 5 | "github.com/sagernet/serenity/constant" 6 | "github.com/sagernet/serenity/option" 7 | C "github.com/sagernet/sing-box/constant" 8 | boxOption "github.com/sagernet/sing-box/option" 9 | ) 10 | 11 | func (t *Template) renderGeoResources(metadata M.Metadata, options *boxOption.Options) { 12 | if len(t.CustomRuleSet) == 0 { 13 | var ( 14 | downloadURL string 15 | downloadDetour string 16 | branchSplit string 17 | ) 18 | if t.EnableJSDelivr { 19 | downloadURL = "https://testingcf.jsdelivr.net/gh/" 20 | if t.DirectTag != "" { 21 | downloadDetour = t.DirectTag 22 | } else { 23 | downloadDetour = DefaultDirectTag 24 | } 25 | branchSplit = "@" 26 | } else { 27 | downloadURL = "https://raw.githubusercontent.com/" 28 | branchSplit = "/" 29 | } 30 | options.Route.RuleSet = []boxOption.RuleSet{ 31 | { 32 | Type: C.RuleSetTypeRemote, 33 | Tag: "geoip-cn", 34 | Format: C.RuleSetFormatBinary, 35 | RemoteOptions: boxOption.RemoteRuleSet{ 36 | URL: downloadURL + "SagerNet/sing-geoip" + branchSplit + "rule-set/geoip-cn.srs", 37 | DownloadDetour: downloadDetour, 38 | }, 39 | }, 40 | { 41 | Type: C.RuleSetTypeRemote, 42 | Tag: "geosite-geolocation-cn", 43 | Format: C.RuleSetFormatBinary, 44 | RemoteOptions: boxOption.RemoteRuleSet{ 45 | URL: downloadURL + "SagerNet/sing-geosite" + branchSplit + "rule-set/geosite-geolocation-cn.srs", 46 | DownloadDetour: downloadDetour, 47 | }, 48 | }, 49 | { 50 | Type: C.RuleSetTypeRemote, 51 | Tag: "geosite-geolocation-!cn", 52 | Format: C.RuleSetFormatBinary, 53 | RemoteOptions: boxOption.RemoteRuleSet{ 54 | URL: downloadURL + "SagerNet/sing-geosite" + branchSplit + "rule-set/geosite-geolocation-!cn.srs", 55 | DownloadDetour: downloadDetour, 56 | }, 57 | }, 58 | } 59 | } 60 | options.Route.RuleSet = append(options.Route.RuleSet, t.renderRuleSet(t.PostRuleSet)...) 61 | } 62 | 63 | func (t *Template) renderRuleSet(ruleSets []option.RuleSet) []boxOption.RuleSet { 64 | var result []boxOption.RuleSet 65 | for _, ruleSet := range ruleSets { 66 | if ruleSet.Type == constant.RuleSetTypeGitHub { 67 | var ( 68 | downloadURL string 69 | downloadDetour string 70 | branchSplit string 71 | ) 72 | if t.EnableJSDelivr { 73 | downloadURL = "https://testingcf.jsdelivr.net/gh/" 74 | if t.DirectTag != "" { 75 | downloadDetour = t.DirectTag 76 | } else { 77 | downloadDetour = DefaultDirectTag 78 | } 79 | branchSplit = "@" 80 | } else { 81 | downloadURL = "https://raw.githubusercontent.com/" 82 | branchSplit = "/" 83 | } 84 | 85 | for _, code := range ruleSet.GitHubOptions.RuleSet { 86 | result = append(result, boxOption.RuleSet{ 87 | Type: C.RuleSetTypeRemote, 88 | Tag: ruleSet.GitHubOptions.Prefix + code, 89 | Format: C.RuleSetFormatBinary, 90 | RemoteOptions: boxOption.RemoteRuleSet{ 91 | URL: downloadURL + 92 | ruleSet.GitHubOptions.Repository + 93 | branchSplit + 94 | ruleSet.GitHubOptions.Path + 95 | code + ".srs", 96 | DownloadDetour: downloadDetour, 97 | }, 98 | }) 99 | } 100 | } else { 101 | result = append(result, ruleSet.DefaultOptions) 102 | } 103 | } 104 | return result 105 | } 106 | -------------------------------------------------------------------------------- /template/render_inbounds.go: -------------------------------------------------------------------------------- 1 | package template 2 | 3 | import ( 4 | "context" 5 | "net/netip" 6 | 7 | M "github.com/sagernet/serenity/common/metadata" 8 | "github.com/sagernet/serenity/common/semver" 9 | C "github.com/sagernet/sing-box/constant" 10 | "github.com/sagernet/sing-box/option" 11 | "github.com/sagernet/sing-dns" 12 | "github.com/sagernet/sing/common" 13 | E "github.com/sagernet/sing/common/exceptions" 14 | "github.com/sagernet/sing/common/json/badjson" 15 | "github.com/sagernet/sing/common/json/badoption" 16 | ) 17 | 18 | func (t *Template) renderInbounds(metadata M.Metadata, options *option.Options) error { 19 | options.Inbounds = t.Inbounds 20 | var domainStrategy option.DomainStrategy 21 | if !t.RemoteResolve { 22 | if t.DomainStrategy != option.DomainStrategy(dns.DomainStrategyAsIS) { 23 | domainStrategy = t.DomainStrategy 24 | } else { 25 | domainStrategy = option.DomainStrategy(dns.DomainStrategyPreferIPv4) 26 | } 27 | } 28 | disableRuleAction := t.DisableRuleAction || (metadata.Version != nil && metadata.Version.LessThan(semver.ParseVersion("1.11.0-alpha.7"))) 29 | autoRedirect := t.AutoRedirect && 30 | !metadata.Platform.IsApple() && 31 | (metadata.Version == nil || metadata.Version.GreaterThanOrEqual(semver.ParseVersion("1.10.0-alpha.2"))) 32 | disableTun := t.DisableTUN && !metadata.Platform.TunOnly() 33 | if !disableTun { 34 | options.Route.AutoDetectInterface = true 35 | address := []netip.Prefix{netip.MustParsePrefix("172.19.0.1/30")} 36 | if !t.DisableIPv6() { 37 | address = append(address, netip.MustParsePrefix("fdfe:dcba:9876::1/126")) 38 | } 39 | tunOptions := &option.TunInboundOptions{ 40 | AutoRoute: true, 41 | Address: address, 42 | } 43 | tunInbound := option.Inbound{ 44 | Type: C.TypeTun, 45 | Options: tunOptions, 46 | } 47 | if autoRedirect { 48 | tunOptions.AutoRedirect = true 49 | if !t.DisableTrafficBypass && metadata.Platform == "" { 50 | tunOptions.RouteExcludeAddressSet = []string{"geoip-cn"} 51 | } 52 | } 53 | if metadata.Platform == M.PlatformUnknown { 54 | tunOptions.StrictRoute = true 55 | } 56 | if disableRuleAction { 57 | //nolint:staticcheck 58 | tunOptions.InboundOptions = option.InboundOptions{ 59 | SniffEnabled: !t.DisableSniff, 60 | } 61 | if t.EnableFakeIP { 62 | tunOptions.DomainStrategy = domainStrategy 63 | } 64 | } 65 | if !t.DisableSystemProxy && metadata.Platform != M.PlatformUnknown { 66 | var httpPort uint16 67 | if t.CustomMixed != nil { 68 | httpPort = t.CustomMixed.Value.ListenPort 69 | } 70 | if httpPort == 0 { 71 | httpPort = DefaultMixedPort 72 | } 73 | tunOptions.Platform = &option.TunPlatformOptions{ 74 | HTTPProxy: &option.HTTPProxyOptions{ 75 | Enabled: true, 76 | ServerOptions: option.ServerOptions{ 77 | Server: "127.0.0.1", 78 | ServerPort: httpPort, 79 | }, 80 | }, 81 | } 82 | } 83 | if t.CustomTUN != nil { 84 | newTUNOptions, err := badjson.MergeFromDestination(context.Background(), tunOptions, t.CustomTUN.Message, true) 85 | if err != nil { 86 | return E.Cause(err, "merge custom tun options") 87 | } 88 | tunInbound.Options = newTUNOptions 89 | } 90 | options.Inbounds = append(options.Inbounds, tunInbound) 91 | } 92 | if disableTun || !t.DisableSystemProxy { 93 | mixedOptions := &option.HTTPMixedInboundOptions{ 94 | ListenOptions: option.ListenOptions{ 95 | Listen: common.Ptr(badoption.Addr(netip.AddrFrom4([4]byte{127, 0, 0, 1}))), 96 | ListenPort: DefaultMixedPort, 97 | }, 98 | SetSystemProxy: metadata.Platform == M.PlatformUnknown && disableTun && !t.DisableSystemProxy, 99 | } 100 | if disableRuleAction { 101 | //nolint:staticcheck 102 | mixedOptions.InboundOptions = option.InboundOptions{ 103 | SniffEnabled: !t.DisableSniff, 104 | DomainStrategy: domainStrategy, 105 | } 106 | } 107 | mixedInbound := option.Inbound{ 108 | Type: C.TypeMixed, 109 | Options: mixedOptions, 110 | } 111 | if t.CustomMixed != nil { 112 | newMixedOptions, err := badjson.MergeFromDestination(context.Background(), mixedOptions, t.CustomMixed.Message, true) 113 | if err != nil { 114 | return E.Cause(err, "merge custom mixed options") 115 | } 116 | mixedInbound.Options = newMixedOptions 117 | } 118 | options.Inbounds = append(options.Inbounds, mixedInbound) 119 | } 120 | return nil 121 | } 122 | -------------------------------------------------------------------------------- /template/render_outbounds.go: -------------------------------------------------------------------------------- 1 | package template 2 | 3 | import ( 4 | "bytes" 5 | "regexp" 6 | "sort" 7 | "text/template" 8 | 9 | M "github.com/sagernet/serenity/common/metadata" 10 | "github.com/sagernet/serenity/common/semver" 11 | "github.com/sagernet/serenity/option" 12 | "github.com/sagernet/serenity/subscription" 13 | C "github.com/sagernet/sing-box/constant" 14 | boxOption "github.com/sagernet/sing-box/option" 15 | "github.com/sagernet/sing/common" 16 | E "github.com/sagernet/sing/common/exceptions" 17 | F "github.com/sagernet/sing/common/format" 18 | ) 19 | 20 | func (t *Template) renderOutbounds(metadata M.Metadata, options *boxOption.Options, outbounds [][]boxOption.Outbound, subscriptions []*subscription.Subscription) error { 21 | disableRuleAction := t.DisableRuleAction || (metadata.Version != nil && metadata.Version.LessThan(semver.ParseVersion("1.11.0-alpha.7"))) 22 | defaultTag := t.DefaultTag 23 | if defaultTag == "" { 24 | defaultTag = DefaultDefaultTag 25 | } 26 | options.Route.Final = defaultTag 27 | directTag := t.DirectTag 28 | if directTag == "" { 29 | directTag = DefaultDirectTag 30 | } 31 | blockTag := t.BlockTag 32 | if blockTag == "" { 33 | blockTag = DefaultBlockTag 34 | } 35 | options.Outbounds = []boxOption.Outbound{ 36 | { 37 | Tag: directTag, 38 | Type: C.TypeDirect, 39 | Options: common.Ptr(common.PtrValueOrDefault(t.CustomDirect)), 40 | }, 41 | { 42 | Tag: defaultTag, 43 | Type: C.TypeSelector, 44 | Options: common.Ptr(common.PtrValueOrDefault(t.CustomSelector)), 45 | }, 46 | } 47 | if disableRuleAction { 48 | options.Outbounds = append(options.Outbounds, 49 | boxOption.Outbound{ 50 | Tag: blockTag, 51 | Type: C.TypeBlock, 52 | Options: &boxOption.StubOptions{}, 53 | }, 54 | boxOption.Outbound{ 55 | Tag: DNSTag, 56 | Type: C.TypeDNS, 57 | Options: &boxOption.StubOptions{}, 58 | }, 59 | ) 60 | } 61 | urlTestTag := t.URLTestTag 62 | if urlTestTag == "" { 63 | urlTestTag = DefaultURLTestTag 64 | } 65 | outboundToString := func(it boxOption.Outbound) string { 66 | return it.Tag 67 | } 68 | var globalOutboundTags []string 69 | if len(outbounds) > 0 { 70 | for _, outbound := range outbounds { 71 | options.Outbounds = append(options.Outbounds, outbound...) 72 | } 73 | globalOutboundTags = common.Map(outbounds, func(it []boxOption.Outbound) string { 74 | return it[0].Tag 75 | }) 76 | } 77 | 78 | var ( 79 | allGroups []boxOption.Outbound 80 | allGroupOutbounds []boxOption.Outbound 81 | groupTags []string 82 | ) 83 | 84 | for _, it := range subscriptions { 85 | if len(it.Servers) == 0 { 86 | continue 87 | } 88 | joinOutbounds := common.Map(it.Servers, func(it boxOption.Outbound) string { 89 | return it.Tag 90 | }) 91 | if it.GenerateSelector { 92 | selectorOptions := common.PtrValueOrDefault(it.CustomSelector) 93 | selectorOutbound := boxOption.Outbound{ 94 | Type: C.TypeSelector, 95 | Tag: it.Name, 96 | Options: &selectorOptions, 97 | } 98 | selectorOptions.Outbounds = append(selectorOptions.Outbounds, joinOutbounds...) 99 | allGroups = append(allGroups, selectorOutbound) 100 | groupTags = append(groupTags, selectorOutbound.Tag) 101 | } 102 | if it.GenerateURLTest { 103 | var urltestTag string 104 | if !it.GenerateSelector { 105 | urltestTag = it.Name 106 | } else if it.URLTestTagSuffix != "" { 107 | urltestTag = it.Name + " " + it.URLTestTagSuffix 108 | } else { 109 | urltestTag = it.Name + " - URLTest" 110 | } 111 | urltestOptions := common.PtrValueOrDefault(t.CustomURLTest) 112 | urltestOutbound := boxOption.Outbound{ 113 | Type: C.TypeURLTest, 114 | Tag: urltestTag, 115 | Options: &urltestOptions, 116 | } 117 | urltestOptions.Outbounds = append(urltestOptions.Outbounds, joinOutbounds...) 118 | allGroups = append(allGroups, urltestOutbound) 119 | groupTags = append(groupTags, urltestOutbound.Tag) 120 | } 121 | if !it.GenerateSelector && !it.GenerateURLTest { 122 | globalOutboundTags = append(globalOutboundTags, joinOutbounds...) 123 | } 124 | allGroupOutbounds = append(allGroupOutbounds, it.Servers...) 125 | } 126 | 127 | var ( 128 | defaultGroups []boxOption.Outbound 129 | globalGroups []boxOption.Outbound 130 | subscriptionGroups = make(map[string][]boxOption.Outbound) 131 | ) 132 | for _, extraGroup := range t.groups { 133 | if extraGroup.Target != option.ExtraGroupTargetSubscription { 134 | continue 135 | } 136 | tmpl := template.New("tag") 137 | if extraGroup.TagPerSubscription != "" { 138 | _, err := tmpl.Parse(extraGroup.TagPerSubscription) 139 | if err != nil { 140 | return E.Cause(err, "parse `tag_per_subscription`: ", extraGroup.TagPerSubscription) 141 | } 142 | } else { 143 | common.Must1(tmpl.Parse("{{ .tag }} ({{ .subscription_name }})")) 144 | } 145 | var outboundTags []string 146 | for _, it := range subscriptions { 147 | subscriptionTags := common.Filter(common.Map(it.Servers, outboundToString), func(outboundTag string) bool { 148 | if len(extraGroup.filter) > 0 { 149 | if !common.Any(extraGroup.filter, func(it *regexp.Regexp) bool { 150 | return it.MatchString(outboundTag) 151 | }) { 152 | return false 153 | } 154 | } 155 | if len(extraGroup.exclude) > 0 { 156 | if common.Any(extraGroup.exclude, func(it *regexp.Regexp) bool { 157 | return it.MatchString(outboundTag) 158 | }) { 159 | return false 160 | } 161 | } 162 | return true 163 | }) 164 | var tagPerSubscription string 165 | if len(outboundTags) == 0 && len(subscriptions) == 1 { 166 | tagPerSubscription = extraGroup.Tag 167 | } else { 168 | var buffer bytes.Buffer 169 | err := tmpl.Execute(&buffer, map[string]interface{}{ 170 | "tag": extraGroup.Tag, 171 | "subscription_name": it.Name, 172 | }) 173 | if err != nil { 174 | return E.Cause(err, "generate tag for extra group: tag=", extraGroup.Tag, ", subscription=", it.Name) 175 | } 176 | tagPerSubscription = buffer.String() 177 | } 178 | groupOutboundPerSubscription := boxOption.Outbound{ 179 | Tag: tagPerSubscription, 180 | Type: extraGroup.Type, 181 | } 182 | switch extraGroup.Type { 183 | case C.TypeSelector: 184 | selectorOptions := common.PtrValueOrDefault(extraGroup.CustomSelector) 185 | groupOutboundPerSubscription.Options = &selectorOptions 186 | selectorOptions.Outbounds = common.Uniq(append(selectorOptions.Outbounds, subscriptionTags...)) 187 | if len(selectorOptions.Outbounds) == 0 { 188 | continue 189 | } 190 | case C.TypeURLTest: 191 | urltestOptions := common.PtrValueOrDefault(extraGroup.CustomURLTest) 192 | groupOutboundPerSubscription.Options = &urltestOptions 193 | urltestOptions.Outbounds = common.Uniq(append(urltestOptions.Outbounds, subscriptionTags...)) 194 | if len(urltestOptions.Outbounds) == 0 { 195 | continue 196 | } 197 | } 198 | subscriptionGroups[it.Name] = append(subscriptionGroups[it.Name], groupOutboundPerSubscription) 199 | } 200 | } 201 | for _, extraGroup := range t.groups { 202 | if extraGroup.Target == option.ExtraGroupTargetSubscription { 203 | continue 204 | } 205 | extraTags := groupTags 206 | for _, group := range subscriptionGroups { 207 | extraTags = append(extraTags, common.Map(group, outboundToString)...) 208 | } 209 | sort.Strings(extraTags) 210 | if len(extraTags) == 0 || extraGroup.filter != nil || extraGroup.exclude != nil { 211 | extraTags = append(extraTags, common.Filter(common.FlatMap(subscriptions, func(it *subscription.Subscription) []string { 212 | return common.Map(it.Servers, outboundToString) 213 | }), func(outboundTag string) bool { 214 | if len(extraGroup.filter) > 0 { 215 | if !common.Any(extraGroup.filter, func(it *regexp.Regexp) bool { 216 | return it.MatchString(outboundTag) 217 | }) { 218 | return false 219 | } 220 | } 221 | if len(extraGroup.exclude) > 0 { 222 | if common.Any(extraGroup.exclude, func(it *regexp.Regexp) bool { 223 | return it.MatchString(outboundTag) 224 | }) { 225 | return false 226 | } 227 | } 228 | return true 229 | })...) 230 | } 231 | groupOutbound := boxOption.Outbound{ 232 | Tag: extraGroup.Tag, 233 | Type: extraGroup.Type, 234 | } 235 | switch extraGroup.Type { 236 | case C.TypeSelector: 237 | selectorOptions := common.PtrValueOrDefault(extraGroup.CustomSelector) 238 | groupOutbound.Options = &selectorOptions 239 | selectorOptions.Outbounds = common.Uniq(append(selectorOptions.Outbounds, extraTags...)) 240 | if len(selectorOptions.Outbounds) == 0 { 241 | continue 242 | } 243 | case C.TypeURLTest: 244 | urltestOptions := common.PtrValueOrDefault(extraGroup.CustomURLTest) 245 | groupOutbound.Options = &urltestOptions 246 | urltestOptions.Outbounds = common.Uniq(append(urltestOptions.Outbounds, extraTags...)) 247 | if len(urltestOptions.Outbounds) == 0 { 248 | continue 249 | } 250 | } 251 | if extraGroup.Target == option.ExtraGroupTargetDefault { 252 | defaultGroups = append(defaultGroups, groupOutbound) 253 | } else { 254 | globalGroups = append(globalGroups, groupOutbound) 255 | } 256 | } 257 | 258 | options.Outbounds = append(options.Outbounds, allGroups...) 259 | if len(defaultGroups) > 0 { 260 | options.Outbounds = append(options.Outbounds, defaultGroups...) 261 | } 262 | if len(globalGroups) > 0 { 263 | options.Outbounds = append(options.Outbounds, globalGroups...) 264 | options.Outbounds = groupJoin(options.Outbounds, defaultTag, false, common.Map(globalGroups, outboundToString)...) 265 | } 266 | for _, it := range subscriptions { 267 | extraGroupOutboundsForSubscription := subscriptionGroups[it.Name] 268 | if len(extraGroupOutboundsForSubscription) > 0 { 269 | options.Outbounds = append(options.Outbounds, extraGroupOutboundsForSubscription...) 270 | options.Outbounds = groupJoin(options.Outbounds, it.Name, true, common.Map(extraGroupOutboundsForSubscription, outboundToString)...) 271 | } 272 | } 273 | options.Outbounds = groupJoin(options.Outbounds, defaultTag, false, groupTags...) 274 | options.Outbounds = groupJoin(options.Outbounds, defaultTag, false, globalOutboundTags...) 275 | options.Outbounds = append(options.Outbounds, allGroupOutbounds...) 276 | return nil 277 | } 278 | 279 | func groupJoin(outbounds []boxOption.Outbound, groupTag string, appendFront bool, groupOutbounds ...string) []boxOption.Outbound { 280 | groupIndex := common.Index(outbounds, func(it boxOption.Outbound) bool { 281 | return it.Tag == groupTag 282 | }) 283 | if groupIndex == -1 { 284 | return outbounds 285 | } 286 | groupOutbound := outbounds[groupIndex] 287 | var outboundPtr *[]string 288 | switch outboundOptions := groupOutbound.Options.(type) { 289 | case *boxOption.SelectorOutboundOptions: 290 | outboundPtr = &outboundOptions.Outbounds 291 | case *boxOption.URLTestOutboundOptions: 292 | outboundPtr = &outboundOptions.Outbounds 293 | default: 294 | panic(F.ToString("unexpected group type: ", groupOutbound.Type)) 295 | } 296 | if appendFront { 297 | *outboundPtr = append(groupOutbounds, *outboundPtr...) 298 | } else { 299 | *outboundPtr = append(*outboundPtr, groupOutbounds...) 300 | } 301 | *outboundPtr = common.Dup(*outboundPtr) 302 | outbounds[groupIndex] = groupOutbound 303 | return outbounds 304 | } 305 | -------------------------------------------------------------------------------- /template/render_route.go: -------------------------------------------------------------------------------- 1 | package template 2 | 3 | import ( 4 | M "github.com/sagernet/serenity/common/metadata" 5 | "github.com/sagernet/serenity/common/semver" 6 | C "github.com/sagernet/sing-box/constant" 7 | "github.com/sagernet/sing-box/option" 8 | N "github.com/sagernet/sing/common/network" 9 | ) 10 | 11 | func (t *Template) renderRoute(metadata M.Metadata, options *option.Options) error { 12 | if options.Route == nil { 13 | options.Route = &option.RouteOptions{ 14 | RuleSet: t.renderRuleSet(t.CustomRuleSet), 15 | } 16 | } 17 | if !t.DisableTrafficBypass { 18 | t.renderGeoResources(metadata, options) 19 | } 20 | disableRuleAction := t.DisableRuleAction || (metadata.Version != nil && metadata.Version.LessThan(semver.ParseVersion("1.11.0-alpha.7"))) 21 | if disableRuleAction { 22 | options.Route.Rules = []option.Rule{ 23 | { 24 | Type: C.RuleTypeLogical, 25 | LogicalOptions: option.LogicalRule{ 26 | RawLogicalRule: option.RawLogicalRule{ 27 | Mode: C.LogicalTypeOr, 28 | Rules: []option.Rule{ 29 | { 30 | Type: C.RuleTypeDefault, 31 | DefaultOptions: option.DefaultRule{ 32 | RawDefaultRule: option.RawDefaultRule{ 33 | Port: []uint16{53}, 34 | }, 35 | }, 36 | }, 37 | { 38 | Type: C.RuleTypeDefault, 39 | DefaultOptions: option.DefaultRule{ 40 | RawDefaultRule: option.RawDefaultRule{ 41 | Protocol: []string{C.ProtocolDNS}, 42 | }, 43 | }, 44 | }, 45 | }, 46 | }, 47 | RuleAction: option.RuleAction{ 48 | Action: C.RuleActionTypeRoute, 49 | RouteOptions: option.RouteActionOptions{ 50 | Outbound: DNSTag, 51 | }, 52 | }, 53 | }, 54 | }, 55 | } 56 | } else { 57 | options.Route.Rules = []option.Rule{ 58 | { 59 | Type: C.RuleTypeDefault, 60 | DefaultOptions: option.DefaultRule{ 61 | RuleAction: option.RuleAction{ 62 | Action: C.RuleActionTypeSniff, 63 | }, 64 | }, 65 | }, 66 | { 67 | Type: C.RuleTypeLogical, 68 | LogicalOptions: option.LogicalRule{ 69 | RawLogicalRule: option.RawLogicalRule{ 70 | Mode: C.LogicalTypeOr, 71 | Rules: []option.Rule{ 72 | { 73 | Type: C.RuleTypeDefault, 74 | DefaultOptions: option.DefaultRule{ 75 | RawDefaultRule: option.RawDefaultRule{ 76 | Port: []uint16{53}, 77 | }, 78 | }, 79 | }, 80 | { 81 | Type: C.RuleTypeDefault, 82 | DefaultOptions: option.DefaultRule{ 83 | RawDefaultRule: option.RawDefaultRule{ 84 | Protocol: []string{C.ProtocolDNS}, 85 | }, 86 | }, 87 | }, 88 | }, 89 | }, 90 | RuleAction: option.RuleAction{ 91 | Action: C.RuleActionTypeHijackDNS, 92 | }, 93 | }, 94 | }, 95 | } 96 | } 97 | directTag := t.DirectTag 98 | defaultTag := t.DefaultTag 99 | if directTag == "" { 100 | directTag = DefaultDirectTag 101 | } 102 | if defaultTag == "" { 103 | defaultTag = DefaultDefaultTag 104 | } 105 | options.Route.Rules = append(options.Route.Rules, option.Rule{ 106 | Type: C.RuleTypeDefault, 107 | DefaultOptions: option.DefaultRule{ 108 | RawDefaultRule: option.RawDefaultRule{ 109 | IPIsPrivate: true, 110 | }, 111 | RuleAction: option.RuleAction{ 112 | Action: C.RuleActionTypeRoute, 113 | RouteOptions: option.RouteActionOptions{ 114 | Outbound: directTag, 115 | }, 116 | }, 117 | }, 118 | }) 119 | if !t.DisableClashMode { 120 | modeGlobal := t.ClashModeGlobal 121 | modeDirect := t.ClashModeDirect 122 | if modeGlobal == "" { 123 | modeGlobal = "Global" 124 | } 125 | if modeDirect == "" { 126 | modeDirect = "Direct" 127 | } 128 | options.Route.Rules = append(options.Route.Rules, option.Rule{ 129 | Type: C.RuleTypeDefault, 130 | DefaultOptions: option.DefaultRule{ 131 | RawDefaultRule: option.RawDefaultRule{ 132 | ClashMode: modeGlobal, 133 | }, 134 | RuleAction: option.RuleAction{ 135 | Action: C.RuleActionTypeRoute, 136 | RouteOptions: option.RouteActionOptions{ 137 | Outbound: defaultTag, 138 | }, 139 | }, 140 | }, 141 | }, option.Rule{ 142 | Type: C.RuleTypeDefault, 143 | DefaultOptions: option.DefaultRule{ 144 | RawDefaultRule: option.RawDefaultRule{ 145 | ClashMode: modeDirect, 146 | }, 147 | RuleAction: option.RuleAction{ 148 | Action: C.RuleActionTypeRoute, 149 | RouteOptions: option.RouteActionOptions{ 150 | Outbound: directTag, 151 | }, 152 | }, 153 | }, 154 | }) 155 | } 156 | if !disableRuleAction { 157 | options.Route.Rules = append(options.Route.Rules, option.Rule{ 158 | Type: C.RuleTypeDefault, 159 | DefaultOptions: option.DefaultRule{ 160 | RuleAction: option.RuleAction{ 161 | Action: C.RuleActionTypeResolve, 162 | }, 163 | }, 164 | }) 165 | } 166 | options.Route.Rules = append(options.Route.Rules, t.PreRules...) 167 | if len(t.CustomRules) == 0 { 168 | if !t.DisableTrafficBypass { 169 | options.Route.Rules = append(options.Route.Rules, option.Rule{ 170 | Type: C.RuleTypeDefault, 171 | DefaultOptions: option.DefaultRule{ 172 | RawDefaultRule: option.RawDefaultRule{ 173 | RuleSet: []string{"geosite-geolocation-cn"}, 174 | }, 175 | RuleAction: option.RuleAction{ 176 | Action: C.RuleActionTypeRoute, 177 | RouteOptions: option.RouteActionOptions{ 178 | Outbound: directTag, 179 | }, 180 | }, 181 | }, 182 | }, option.Rule{ 183 | Type: C.RuleTypeLogical, 184 | LogicalOptions: option.LogicalRule{ 185 | RawLogicalRule: option.RawLogicalRule{ 186 | Mode: C.LogicalTypeAnd, 187 | Rules: []option.Rule{ 188 | { 189 | Type: C.RuleTypeDefault, 190 | DefaultOptions: option.DefaultRule{ 191 | RawDefaultRule: option.RawDefaultRule{ 192 | RuleSet: []string{"geoip-cn"}, 193 | }, 194 | }, 195 | }, 196 | { 197 | Type: C.RuleTypeDefault, 198 | DefaultOptions: option.DefaultRule{ 199 | RawDefaultRule: option.RawDefaultRule{ 200 | RuleSet: []string{"geosite-geolocation-!cn"}, 201 | Invert: true, 202 | }, 203 | }, 204 | }, 205 | }, 206 | }, 207 | RuleAction: option.RuleAction{ 208 | Action: C.RuleActionTypeRoute, 209 | RouteOptions: option.RouteActionOptions{ 210 | Outbound: directTag, 211 | }, 212 | }, 213 | }, 214 | }) 215 | } 216 | } else { 217 | options.Route.Rules = append(options.Route.Rules, t.CustomRules...) 218 | } 219 | if !t.DisableTrafficBypass && !t.DisableDefaultRules { 220 | blockTag := t.BlockTag 221 | if blockTag == "" { 222 | blockTag = DefaultBlockTag 223 | } 224 | options.Route.Rules = append(options.Route.Rules, option.Rule{ 225 | Type: C.RuleTypeLogical, 226 | LogicalOptions: option.LogicalRule{ 227 | RawLogicalRule: option.RawLogicalRule{ 228 | Mode: C.LogicalTypeOr, 229 | Rules: []option.Rule{ 230 | { 231 | Type: C.RuleTypeDefault, 232 | DefaultOptions: option.DefaultRule{ 233 | RawDefaultRule: option.RawDefaultRule{ 234 | Network: []string{N.NetworkUDP}, 235 | Port: []uint16{443}, 236 | }, 237 | }, 238 | }, 239 | }, 240 | }, 241 | RuleAction: option.RuleAction{ 242 | Action: C.RuleActionTypeRoute, 243 | RouteOptions: option.RouteActionOptions{ 244 | Outbound: blockTag, 245 | }, 246 | }, 247 | }, 248 | }) 249 | } 250 | if metadata.Version != nil && metadata.Version.GreaterThanOrEqual(semver.ParseVersion("1.12.0-alpha.1")) { 251 | options.Route.DefaultDomainResolver = &option.DomainResolveOptions{ 252 | Server: DNSLocalTag, 253 | } 254 | } 255 | return nil 256 | } 257 | -------------------------------------------------------------------------------- /template/template.go: -------------------------------------------------------------------------------- 1 | package template 2 | 3 | import ( 4 | "context" 5 | "regexp" 6 | 7 | M "github.com/sagernet/serenity/common/metadata" 8 | "github.com/sagernet/serenity/option" 9 | "github.com/sagernet/serenity/subscription" 10 | "github.com/sagernet/serenity/template/filter" 11 | boxOption "github.com/sagernet/sing-box/option" 12 | E "github.com/sagernet/sing/common/exceptions" 13 | ) 14 | 15 | const ( 16 | DefaultMixedPort = 8080 17 | DNSDefaultTag = "default" 18 | DNSLocalTag = "local" 19 | DNSLocalSetupTag = "local_setup" 20 | DNSFakeIPTag = "remote" 21 | DefaultDNS = "tls://8.8.8.8" 22 | DefaultDNSLocal = "https://223.5.5.5/dns-query" 23 | DefaultDefaultTag = "default" 24 | DefaultDirectTag = "direct" 25 | DefaultBlockTag = "block" 26 | DNSTag = "dns" 27 | DefaultURLTestTag = "URLTest" 28 | ) 29 | 30 | var Default = new(Template) 31 | 32 | type Template struct { 33 | option.Template 34 | groups []*ExtraGroup 35 | } 36 | 37 | type ExtraGroup struct { 38 | option.ExtraGroup 39 | filter []*regexp.Regexp 40 | exclude []*regexp.Regexp 41 | } 42 | 43 | func (t *Template) Render(ctx context.Context, metadata M.Metadata, profileName string, outbounds [][]boxOption.Outbound, subscriptions []*subscription.Subscription) (*boxOption.Options, error) { 44 | var options boxOption.Options 45 | options.Log = t.Log 46 | err := t.renderDNS(ctx, metadata, &options) 47 | if err != nil { 48 | return nil, E.Cause(err, "render dns") 49 | } 50 | err = t.renderRoute(metadata, &options) 51 | if err != nil { 52 | return nil, E.Cause(err, "render route") 53 | } 54 | err = t.renderInbounds(metadata, &options) 55 | if err != nil { 56 | return nil, E.Cause(err, "render inbounds") 57 | } 58 | err = t.renderOutbounds(metadata, &options, outbounds, subscriptions) 59 | if err != nil { 60 | return nil, E.Cause(err, "render outbounds") 61 | } 62 | err = t.renderExperimental(ctx, metadata, &options, profileName) 63 | if err != nil { 64 | return nil, E.Cause(err, "render experimental") 65 | } 66 | err = filter.Filter(metadata, &options) 67 | if err != nil { 68 | return nil, E.Cause(err, "filter options") 69 | } 70 | return &options, nil 71 | } 72 | --------------------------------------------------------------------------------