From 1e27e80af9b10c99a293260065c52e65b03957f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Tue, 19 Dec 2023 20:00:00 +0800 Subject: [PATCH] Init commit --- .github/update_dependencies.sh | 5 + .github/workflows/debug.yml | 51 ++++ .github/workflows/docker.yml | 47 ++++ .github/workflows/lint.yml | 39 +++ .gitignore | 11 + .golangci.yml | 18 ++ .goreleaser.yaml | 77 ++++++ Dockerfile | 26 ++ LICENSE | 17 ++ Makefile | 65 +++++ README.md | 26 ++ cmd/serenity/cmd_check.go | 40 +++ cmd/serenity/cmd_format.go | 75 ++++++ cmd/serenity/cmd_run.go | 207 ++++++++++++++++ cmd/serenity/cmd_version.go | 52 ++++ cmd/serenity/main.go | 56 +++++ common/cachefile/cache.go | 114 +++++++++ common/cachefile/subscription.go | 76 ++++++ common/metadata/metadata.go | 90 +++++++ common/semver/version.go | 148 +++++++++++ constant/version.go | 37 +++ docs/CNAME | 1 + docs/assets/icon.svg | 37 +++ docs/changelog.md | 7 + docs/configuration/index.md | 75 ++++++ docs/configuration/profile.md | 42 ++++ docs/configuration/subscription.md | 114 +++++++++ docs/configuration/template.md | 283 ++++++++++++++++++++++ docs/configuration/user.md | 36 +++ docs/index.md | 31 +++ docs/index.zh.md | 31 +++ docs/installation/build-from-source.md | 23 ++ docs/installation/build-from-source.zh.md | 23 ++ docs/installation/docker.md | 31 +++ docs/installation/docker.zh.md | 31 +++ docs/installation/package-manager.md | 43 ++++ docs/installation/package-manager.zh.md | 43 ++++ docs/installation/scripts/arch-install.sh | 22 ++ docs/installation/scripts/deb-install.sh | 23 ++ docs/installation/scripts/rpm-install.sh | 22 ++ docs/support.md | 13 + docs/support.zh.md | 14 ++ go.mod | 102 ++++++++ go.sum | 249 +++++++++++++++++++ mkdocs.yml | 113 +++++++++ option/message.go | 17 ++ option/options.go | 80 ++++++ option/template.go | 77 ++++++ release/config/config.json | 8 + release/config/serenity.service | 16 ++ release/config/serenity@.service | 16 ++ release/local/enable.sh | 7 + release/local/install.sh | 20 ++ release/local/install_go.sh | 9 + release/local/reinstall.sh | 18 ++ release/local/serenity.service | 16 ++ release/local/uninstall.sh | 8 + release/local/update.sh | 14 ++ server/profile.go | 144 +++++++++++ server/server.go | 192 +++++++++++++++ server/server_render.go | 124 ++++++++++ subscription/deduplication.go | 87 +++++++ subscription/parser/clash.go | 283 ++++++++++++++++++++++ subscription/parser/link.go | 22 ++ subscription/parser/link_shadowsocks.go | 67 +++++ subscription/parser/parser.go | 25 ++ subscription/parser/raw.go | 43 ++++ subscription/parser/sing_box.go | 28 +++ subscription/parser/sip008.go | 51 ++++ subscription/process.go | 150 ++++++++++++ subscription/subscription.go | 211 ++++++++++++++++ template/filter/filter.go | 16 ++ template/filter/filter_170.go | 90 +++++++ template/filter/filter_180.go | 58 +++++ template/filter/filter_rule.go | 58 +++++ template/manager.go | 80 ++++++ template/render_dns.go | 186 ++++++++++++++ template/render_experimental.go | 66 +++++ template/render_geo_resources.go | 99 ++++++++ template/render_inbounds.go | 107 ++++++++ template/render_outbounds.go | 183 ++++++++++++++ template/render_route.go | 177 ++++++++++++++ template/template.go | 66 +++++ 83 files changed, 5605 insertions(+) create mode 100755 .github/update_dependencies.sh create mode 100644 .github/workflows/debug.yml create mode 100644 .github/workflows/docker.yml create mode 100644 .github/workflows/lint.yml create mode 100644 .gitignore create mode 100644 .golangci.yml create mode 100644 .goreleaser.yaml create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 README.md create mode 100644 cmd/serenity/cmd_check.go create mode 100644 cmd/serenity/cmd_format.go create mode 100644 cmd/serenity/cmd_run.go create mode 100644 cmd/serenity/cmd_version.go create mode 100644 cmd/serenity/main.go create mode 100644 common/cachefile/cache.go create mode 100644 common/cachefile/subscription.go create mode 100644 common/metadata/metadata.go create mode 100644 common/semver/version.go create mode 100644 constant/version.go create mode 100644 docs/CNAME create mode 100644 docs/assets/icon.svg create mode 100644 docs/changelog.md create mode 100644 docs/configuration/index.md create mode 100644 docs/configuration/profile.md create mode 100644 docs/configuration/subscription.md create mode 100644 docs/configuration/template.md create mode 100644 docs/configuration/user.md create mode 100644 docs/index.md create mode 100644 docs/index.zh.md create mode 100644 docs/installation/build-from-source.md create mode 100644 docs/installation/build-from-source.zh.md create mode 100644 docs/installation/docker.md create mode 100644 docs/installation/docker.zh.md create mode 100644 docs/installation/package-manager.md create mode 100644 docs/installation/package-manager.zh.md create mode 100644 docs/installation/scripts/arch-install.sh create mode 100644 docs/installation/scripts/deb-install.sh create mode 100644 docs/installation/scripts/rpm-install.sh create mode 100644 docs/support.md create mode 100644 docs/support.zh.md create mode 100644 go.mod create mode 100644 go.sum create mode 100644 mkdocs.yml create mode 100644 option/message.go create mode 100644 option/options.go create mode 100644 option/template.go create mode 100644 release/config/config.json create mode 100644 release/config/serenity.service create mode 100644 release/config/serenity@.service create mode 100755 release/local/enable.sh create mode 100755 release/local/install.sh create mode 100755 release/local/install_go.sh create mode 100755 release/local/reinstall.sh create mode 100644 release/local/serenity.service create mode 100755 release/local/uninstall.sh create mode 100755 release/local/update.sh create mode 100644 server/profile.go create mode 100644 server/server.go create mode 100644 server/server_render.go create mode 100644 subscription/deduplication.go create mode 100644 subscription/parser/clash.go create mode 100644 subscription/parser/link.go create mode 100644 subscription/parser/link_shadowsocks.go create mode 100644 subscription/parser/parser.go create mode 100644 subscription/parser/raw.go create mode 100644 subscription/parser/sing_box.go create mode 100644 subscription/parser/sip008.go create mode 100644 subscription/process.go create mode 100644 subscription/subscription.go create mode 100644 template/filter/filter.go create mode 100644 template/filter/filter_170.go create mode 100644 template/filter/filter_180.go create mode 100644 template/filter/filter_rule.go create mode 100644 template/manager.go create mode 100644 template/render_dns.go create mode 100644 template/render_experimental.go create mode 100644 template/render_geo_resources.go create mode 100644 template/render_inbounds.go create mode 100644 template/render_outbounds.go create mode 100644 template/render_route.go create mode 100644 template/template.go diff --git a/.github/update_dependencies.sh b/.github/update_dependencies.sh new file mode 100755 index 0000000..4702ddf --- /dev/null +++ b/.github/update_dependencies.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash + +PROJECTS=$(dirname "$0")/../.. +go get -x github.com/sagernet/$1@$(git -C $PROJECTS/$1 rev-parse HEAD) +go mod tidy diff --git a/.github/workflows/debug.yml b/.github/workflows/debug.yml new file mode 100644 index 0000000..bb10733 --- /dev/null +++ b/.github/workflows/debug.yml @@ -0,0 +1,51 @@ +name: Debug build + +on: + push: + branches: + - main + - dev + paths-ignore: + - '**.md' + - '.github/**' + - '!.github/workflows/debug.yml' + pull_request: + branches: + - main + - dev + +jobs: + build: + name: Debug build + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v3 + with: + fetch-depth: 0 + - name: Get latest go version + id: version + run: | + echo 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') >> $GITHUB_OUTPUT + - name: Setup Go + uses: actions/setup-go@v4 + with: + go-version: ${{ steps.version.outputs.go_version }} + - name: Cache go module + uses: actions/cache@v3 + with: + path: | + ~/go/pkg/mod + key: go-${{ hashFiles('**/go.sum') }} + - name: Add cache to Go proxy + run: | + version=`git rev-parse HEAD` + mkdir build + pushd build + go mod init build + go get -v github.com/sagernet/serenity@$version + popd + continue-on-error: true + - name: Run Test + run: | + go test -v ./... \ No newline at end of file diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml new file mode 100644 index 0000000..e7c94f2 --- /dev/null +++ b/.github/workflows/docker.yml @@ -0,0 +1,47 @@ +name: Build Docker Images +on: + workflow_dispatch: + inputs: + tag: + description: "The tag version you want to build" +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4 + - name: Setup Docker Buildx + uses: docker/setup-buildx-action@v3 + - name: Setup QEMU for Docker Buildx + uses: docker/setup-qemu-action@v3 + - name: Login to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Docker metadata + id: metadata + uses: docker/metadata-action@v5 + with: + images: ghcr.io/sagernet/serenity + - name: Get tag to build + id: tag + run: | + echo "latest=ghcr.io/sagernet/serenity:latest" >> $GITHUB_OUTPUT + if [[ -z "${{ github.event.inputs.tag }}" ]]; then + echo "versioned=ghcr.io/sagernet/serenity:${{ github.ref_name }}" >> $GITHUB_OUTPUT + else + echo "versioned=ghcr.io/sagernet/serenity:${{ github.event.inputs.tag }}" >> $GITHUB_OUTPUT + fi + - name: Build and release Docker images + uses: docker/build-push-action@v5 + with: + platforms: linux/386,linux/amd64,linux/arm64,linux/s390x + target: dist + build-args: | + BUILDKIT_CONTEXT_KEEP_GIT_DIR=1 + tags: | + ${{ steps.tag.outputs.latest }} + ${{ steps.tag.outputs.versioned }} + push: true diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..961b8a2 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,39 @@ +name: Lint + +on: + push: + branches: + - main + - dev + paths-ignore: + - '**.md' + - '.github/**' + - '!.github/workflows/lint.yml' + pull_request: + branches: + - main + - dev + +jobs: + build: + name: Build + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4 + with: + fetch-depth: 0 + - name: Get latest go version + id: version + run: | + echo 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') >> $GITHUB_OUTPUT + - name: Setup Go + uses: actions/setup-go@v4 + with: + go-version: ${{ steps.version.outputs.go_version }} + - name: golangci-lint + uses: golangci/golangci-lint-action@v3 + with: + version: latest + args: --timeout=30m + install-mode: binary \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1213931 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +/.idea/ +/vendor/ +/*.json +/*.db +/site/ +/bin/ +/dist/ +/build/ +.DS_Store +/serenity +/serenity.exe diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..58f7fe1 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,18 @@ +linters: + disable-all: true + enable: + - gofumpt + - govet + - gci + - staticcheck + - paralleltest + +linters-settings: + gci: + custom-order: true + sections: + - standard + - prefix(github.com/sagernet/) + - default + staticcheck: + go: '1.20' diff --git a/.goreleaser.yaml b/.goreleaser.yaml new file mode 100644 index 0000000..88ee0f7 --- /dev/null +++ b/.goreleaser.yaml @@ -0,0 +1,77 @@ +project_name: serenity +builds: + - id: main + main: ./cmd/serenity + flags: + - -v + - -trimpath + asmflags: + - all=-trimpath={{.Env.GOPATH}} + gcflags: + - all=-trimpath={{.Env.GOPATH}} + ldflags: + - -X github.com/sagernet/serenity/constant.Version={{ .Version }} -s -w -buildid= + env: + - CGO_ENABLED=0 + targets: + - linux_386 + - linux_amd64_v1 + - linux_arm64 + - linux_arm_7 + - linux_s390x + mod_timestamp: '{{ .CommitTimestamp }}' +snapshot: + name_template: "{{ .Version }}.{{ .ShortCommit }}" +archives: + - id: archive + builds: + - main + - android + format: tar.gz + format_overrides: + - goos: windows + format: zip + wrap_in_directory: true + files: + - LICENSE + name_template: '{{ .ProjectName }}-{{ .Version }}-{{ .Os }}-{{ .Arch }}{{ with .Arm }}v{{ . }}{{ end }}{{ with .Mips }}_{{ . }}{{ end }}{{ if not (eq .Amd64 "v1") }}{{ .Amd64 }}{{ end }}' +nfpms: + - id: package + package_name: serenity + file_name_template: '{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}{{ with .Arm }}v{{ . }}{{ end }}{{ with .Mips }}_{{ . }}{{ end }}{{ if not (eq .Amd64 "v1") }}{{ .Amd64 }}{{ end }}' + vendor: sagernet + homepage: https://serenity.sagernet.org/ + maintainer: nekohasekai + description: The configuration generator for sing-box. + license: GPLv3 or later + formats: + - deb + - rpm + - archlinux + priority: extra + contents: + - src: release/config/config.json + dst: /etc/serenity/config.json + type: config + - src: release/config/serenity.service + dst: /etc/systemd/system/serenity.service + - src: release/config/serenity@.service + dst: /etc/systemd/system/serenity@.service + - src: LICENSE + dst: /usr/share/licenses/serenity/LICENSE +source: + enabled: false + name_template: '{{ .ProjectName }}-{{ .Version }}.source' + prefix_template: '{{ .ProjectName }}-{{ .Version }}/' +checksum: + disable: true + name_template: '{{ .ProjectName }}-{{ .Version }}.checksum' +signs: + - artifacts: checksum +release: + github: + owner: SagerNet + name: serenity + name_template: '{{ if .IsSnapshot }}{{ nightly }}{{ else }}{{ .Version }}{{ end }}' + draft: true + mode: replace \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..d619b33 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,26 @@ +FROM --platform=$BUILDPLATFORM golang:1.21-alpine AS builder +LABEL maintainer="nekohasekai " +COPY . /go/src/github.com/sagernet/serenity +WORKDIR /go/src/github.com/sagernet/serenity +ARG TARGETOS TARGETARCH +ARG GOPROXY="" +ENV GOPROXY ${GOPROXY} +ENV CGO_ENABLED=0 +ENV GOOS=$TARGETOS +ENV GOARCH=$TARGETARCH +RUN set -ex \ + && apk add git build-base \ + && export COMMIT=$(git rev-parse --short HEAD) \ + && export VERSION=$(go run ./cmd/internal/read_tag) \ + && go build -v -trimpath \ + -o /go/bin/serenity \ + -ldflags "-X \"github.com/sagernet/serenity/cmd/serenity.Version=$VERSION\" -s -w -buildid=" \ + ./cmd/serenity +FROM --platform=$TARGETPLATFORM alpine AS dist +LABEL maintainer="nekohasekai " +RUN set -ex \ + && apk upgrade \ + && apk add bash tzdata ca-certificates \ + && rm -rf /var/cache/apk/* +COPY --from=builder /go/bin/serenity /usr/local/bin/serenity +ENTRYPOINT ["serenity"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..175f350 --- /dev/null +++ b/LICENSE @@ -0,0 +1,17 @@ +Copyright (C) 2022 by nekohasekai + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . + +In addition, no derivative work may use the name or imply association +with this application without prior consent. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..be45fca --- /dev/null +++ b/Makefile @@ -0,0 +1,65 @@ +NAME = serenity +COMMIT = $(shell git rev-parse --short HEAD) +TAG = $(shell git describe --tags --always) +VERSION = $(TAG:v%=%) + +PARAMS = -v -trimpath -ldflags "-X 'github.com/sagernet/serenity/constant.Version=$(VERSION)' -s -w -buildid=" +MAIN_PARAMS = $(PARAMS) +MAIN = ./cmd/serenity +PREFIX ?= $(shell go env GOPATH) + +.PHONY: release docs + +build: + go build $(MAIN_PARAMS) $(MAIN) + +install: + go build -o $(PREFIX)/bin/$(NAME) $(MAIN_PARAMS) $(MAIN) + +fmt: + @gofumpt -l -w . + @gofmt -s -w . + @gci write --custom-order -s standard -s "prefix(github.com/sagernet/)" -s "default" . + +fmt_install: + go install -v mvdan.cc/gofumpt@latest + go install -v github.com/daixiang0/gci@latest + +lint: + GOOS=linux golangci-lint run ./... + GOOS=android golangci-lint run ./... + GOOS=windows golangci-lint run ./... + GOOS=darwin golangci-lint run ./... + GOOS=freebsd golangci-lint run ./... + +lint_install: + go install -v github.com/golangci/golangci-lint/cmd/golangci-lint@latest + +release: + goreleaser release --clean --skip-publish || exit 1 + mkdir dist/release + mv dist/*.tar.gz dist/*.deb dist/*.rpm dist/*.pkg.tar.zst dist/release + ghr --replace --draft --prerelease -p 3 "v${VERSION}" dist/release + rm -r dist/release + +release_install: + go install -v github.com/goreleaser/goreleaser@latest + go install -v github.com/tcnksm/ghr@latest + +docs: + mkdocs serve + +publish_docs: + mkdocs gh-deploy -m "Update" --force --ignore-version --no-history + +docs_install: + pip install --force-reinstall mkdocs-material=="9.*" mkdocs-static-i18n=="1.2.*" + +clean: + rm -rf bin dist serenity + rm -f $(shell go env GOPATH)/serenity + +update: + git fetch + git reset FETCH_HEAD --hard + git clean -fdx \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..b8fac01 --- /dev/null +++ b/README.md @@ -0,0 +1,26 @@ +# serenity + +The configuration generator for sing-box. + +## Documentation + +https://serenity.sagernet.org + +## License + +``` +Copyright (C) 2022 by nekohasekai + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +``` diff --git a/cmd/serenity/cmd_check.go b/cmd/serenity/cmd_check.go new file mode 100644 index 0000000..6cbe72a --- /dev/null +++ b/cmd/serenity/cmd_check.go @@ -0,0 +1,40 @@ +package main + +import ( + "context" + + "github.com/sagernet/serenity/server" + "github.com/sagernet/sing-box/log" + + "github.com/spf13/cobra" +) + +var commandCheck = &cobra.Command{ + Use: "check", + Short: "Check configuration", + Run: func(cmd *cobra.Command, args []string) { + err := check() + if err != nil { + log.Fatal(err) + } + }, + Args: cobra.NoArgs, +} + +func init() { + mainCommand.AddCommand(commandCheck) +} + +func check() error { + options, err := readConfigAndMerge() + if err != nil { + return err + } + ctx, cancel := context.WithCancel(context.Background()) + instance, err := server.New(ctx, options) + if err == nil { + instance.Close() + } + cancel() + return err +} diff --git a/cmd/serenity/cmd_format.go b/cmd/serenity/cmd_format.go new file mode 100644 index 0000000..fc47c5a --- /dev/null +++ b/cmd/serenity/cmd_format.go @@ -0,0 +1,75 @@ +package main + +import ( + "bytes" + "os" + "path/filepath" + + "github.com/sagernet/sing-box/log" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/json" + "github.com/sagernet/sing/common/json/badjson" + + "github.com/spf13/cobra" +) + +var commandFormatFlagWrite bool + +var commandFormat = &cobra.Command{ + Use: "format", + Short: "Format configuration", + Run: func(cmd *cobra.Command, args []string) { + err := format() + if err != nil { + log.Fatal(err) + } + }, + Args: cobra.NoArgs, +} + +func init() { + commandFormat.Flags().BoolVarP(&commandFormatFlagWrite, "write", "w", false, "write result to (source) file instead of stdout") + mainCommand.AddCommand(commandFormat) +} + +func format() error { + optionsList, err := readConfig() + if err != nil { + return err + } + for _, optionsEntry := range optionsList { + optionsEntry.options, err = badjson.Omitempty(optionsEntry.options) + if err != nil { + return err + } + buffer := new(bytes.Buffer) + encoder := json.NewEncoder(buffer) + encoder.SetIndent("", " ") + err = encoder.Encode(optionsEntry.options) + if err != nil { + return E.Cause(err, "encode config") + } + outputPath, _ := filepath.Abs(optionsEntry.path) + if !commandFormatFlagWrite { + if len(optionsList) > 1 { + os.Stdout.WriteString(outputPath + "\n") + } + os.Stdout.WriteString(buffer.String() + "\n") + continue + } + if bytes.Equal(optionsEntry.content, buffer.Bytes()) { + continue + } + output, err := os.Create(optionsEntry.path) + if err != nil { + return E.Cause(err, "open output") + } + _, err = output.Write(buffer.Bytes()) + output.Close() + if err != nil { + return E.Cause(err, "write output") + } + os.Stderr.WriteString(outputPath + "\n") + } + return nil +} diff --git a/cmd/serenity/cmd_run.go b/cmd/serenity/cmd_run.go new file mode 100644 index 0000000..286c063 --- /dev/null +++ b/cmd/serenity/cmd_run.go @@ -0,0 +1,207 @@ +package main + +import ( + "context" + "io" + "os" + "os/signal" + "path/filepath" + runtimeDebug "runtime/debug" + "sort" + "strings" + "syscall" + "time" + + "github.com/sagernet/serenity/option" + "github.com/sagernet/serenity/server" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + boxOption "github.com/sagernet/sing-box/option" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/json" + "github.com/sagernet/sing/common/json/badjson" + + "github.com/spf13/cobra" +) + +var commandRun = &cobra.Command{ + Use: "run", + Short: "Run service", + Run: func(cmd *cobra.Command, args []string) { + err := run() + if err != nil { + log.Fatal(err) + } + }, +} + +func init() { + mainCommand.AddCommand(commandRun) +} + +type OptionsEntry struct { + content []byte + path string + options option.Options +} + +func readConfigAt(path string) (*OptionsEntry, error) { + var ( + configContent []byte + err error + ) + if path == "stdin" { + configContent, err = io.ReadAll(os.Stdin) + } else { + configContent, err = os.ReadFile(path) + } + if err != nil { + return nil, E.Cause(err, "read config at ", path) + } + options, err := json.UnmarshalExtended[option.Options](configContent) + if err != nil { + return nil, E.Cause(err, "decode config at ", path) + } + return &OptionsEntry{ + content: configContent, + path: path, + options: options, + }, nil +} + +func readConfig() ([]*OptionsEntry, error) { + var optionsList []*OptionsEntry + for _, path := range configPaths { + optionsEntry, err := readConfigAt(path) + if err != nil { + return nil, err + } + optionsList = append(optionsList, optionsEntry) + } + for _, directory := range configDirectories { + entries, err := os.ReadDir(directory) + if err != nil { + return nil, E.Cause(err, "read config directory at ", directory) + } + for _, entry := range entries { + if !strings.HasSuffix(entry.Name(), ".json") || entry.IsDir() { + continue + } + optionsEntry, err := readConfigAt(filepath.Join(directory, entry.Name())) + if err != nil { + return nil, err + } + optionsList = append(optionsList, optionsEntry) + } + } + sort.Slice(optionsList, func(i, j int) bool { + return optionsList[i].path < optionsList[j].path + }) + return optionsList, nil +} + +func readConfigAndMerge() (option.Options, error) { + optionsList, err := readConfig() + if err != nil { + return option.Options{}, err + } + if len(optionsList) == 1 { + return optionsList[0].options, nil + } + var mergedMessage json.RawMessage + for _, options := range optionsList { + mergedMessage, err = badjson.MergeJSON(options.options.RawMessage, mergedMessage) + if err != nil { + return option.Options{}, E.Cause(err, "merge config at ", options.path) + } + } + var mergedOptions option.Options + err = mergedOptions.UnmarshalJSON(mergedMessage) + if err != nil { + return option.Options{}, E.Cause(err, "unmarshal merged config") + } + return mergedOptions, nil +} + +func create() (*server.Server, context.CancelFunc, error) { + options, err := readConfigAndMerge() + if err != nil { + return nil, nil, err + } + if disableColor { + if options.Log == nil { + options.Log = &boxOption.LogOptions{} + } + options.Log.DisableColor = true + } + ctx, cancel := context.WithCancel(context.Background()) + instance, err := server.New(ctx, options) + if err != nil { + cancel() + return nil, nil, E.Cause(err, "create service") + } + + osSignals := make(chan os.Signal, 1) + signal.Notify(osSignals, os.Interrupt, syscall.SIGTERM, syscall.SIGHUP) + defer func() { + signal.Stop(osSignals) + close(osSignals) + }() + startCtx, finishStart := context.WithCancel(context.Background()) + go func() { + _, loaded := <-osSignals + if loaded { + cancel() + closeMonitor(startCtx) + } + }() + err = instance.Start() + finishStart() + if err != nil { + cancel() + return nil, nil, E.Cause(err, "start service") + } + return instance, cancel, nil +} + +func run() error { + osSignals := make(chan os.Signal, 1) + signal.Notify(osSignals, os.Interrupt, syscall.SIGTERM, syscall.SIGHUP) + defer signal.Stop(osSignals) + for { + instance, cancel, err := create() + if err != nil { + return err + } + runtimeDebug.FreeOSMemory() + for { + osSignal := <-osSignals + if osSignal == syscall.SIGHUP { + err = check() + if err != nil { + log.Error(E.Cause(err, "reload service")) + continue + } + } + cancel() + closeCtx, closed := context.WithCancel(context.Background()) + go closeMonitor(closeCtx) + instance.Close() + closed() + if osSignal != syscall.SIGHUP { + return nil + } + break + } + } +} + +func closeMonitor(ctx context.Context) { + time.Sleep(C.DefaultStopFatalTimeout) + select { + case <-ctx.Done(): + return + default: + } + log.Fatal("sing-box did not close!") +} diff --git a/cmd/serenity/cmd_version.go b/cmd/serenity/cmd_version.go new file mode 100644 index 0000000..18b1573 --- /dev/null +++ b/cmd/serenity/cmd_version.go @@ -0,0 +1,52 @@ +package main + +import ( + "os" + "runtime" + "runtime/debug" + + C "github.com/sagernet/serenity/constant" + + "github.com/spf13/cobra" +) + +var commandVersion = &cobra.Command{ + Use: "version", + Short: "Print current version of serenity", + Run: printVersion, + Args: cobra.NoArgs, +} + +var nameOnly bool + +func init() { + commandVersion.Flags().BoolVarP(&nameOnly, "name", "n", false, "print version name only") + mainCommand.AddCommand(commandVersion) +} + +func printVersion(cmd *cobra.Command, args []string) { + if nameOnly { + os.Stdout.WriteString(C.Version + "\n") + return + } + version := "serenity version " + C.Version + " (sing-box " + C.CoreVersion() + ")\n\n" + version += "Environment: " + runtime.Version() + " " + runtime.GOOS + "/" + runtime.GOARCH + "\n" + + var revision string + + debugInfo, loaded := debug.ReadBuildInfo() + if loaded { + for _, setting := range debugInfo.Settings { + switch setting.Key { + case "vcs.revision": + revision = setting.Value + } + } + } + + if revision != "" { + version += "Revision: " + revision + "\n" + } + + os.Stdout.WriteString(version) +} diff --git a/cmd/serenity/main.go b/cmd/serenity/main.go new file mode 100644 index 0000000..a39c2cc --- /dev/null +++ b/cmd/serenity/main.go @@ -0,0 +1,56 @@ +package main + +import ( + "context" + "os" + "time" + + _ "github.com/sagernet/sing-box/include" + "github.com/sagernet/sing-box/log" + + "github.com/spf13/cobra" +) + +var ( + configPaths []string + configDirectories []string + workingDir string + disableColor bool +) + +var mainCommand = &cobra.Command{ + Use: "serenity", + Short: "the configuration generator for sing-box", + PersistentPreRun: preRun, +} + +func init() { + mainCommand.PersistentFlags().StringArrayVarP(&configPaths, "config", "c", nil, "set configuration file path") + mainCommand.PersistentFlags().StringArrayVarP(&configDirectories, "config-directory", "C", nil, "set configuration directory path") + mainCommand.PersistentFlags().StringVarP(&workingDir, "directory", "D", "", "set working directory") + mainCommand.PersistentFlags().BoolVarP(&disableColor, "disable-color", "", false, "disable color output") +} + +func main() { + if err := mainCommand.Execute(); err != nil { + log.Fatal(err) + } +} + +func preRun(cmd *cobra.Command, args []string) { + if disableColor { + log.SetStdLogger(log.NewDefaultFactory(context.Background(), log.Formatter{BaseTime: time.Now(), DisableColors: true}, os.Stderr, "", nil, false).Logger()) + } + if workingDir != "" { + _, err := os.Stat(workingDir) + if err != nil { + os.MkdirAll(workingDir, 0o777) + } + if err := os.Chdir(workingDir); err != nil { + log.Fatal(err) + } + } + if len(configPaths) == 0 && len(configDirectories) == 0 { + configPaths = append(configPaths, "config.json") + } +} diff --git a/common/cachefile/cache.go b/common/cachefile/cache.go new file mode 100644 index 0000000..0c3a061 --- /dev/null +++ b/common/cachefile/cache.go @@ -0,0 +1,114 @@ +package cachefile + +import ( + "errors" + "os" + "time" + + "github.com/sagernet/bbolt" + bboltErrors "github.com/sagernet/bbolt/errors" + "github.com/sagernet/sing/common" + E "github.com/sagernet/sing/common/exceptions" +) + +var ( + bucketSubscription = []byte("subscription") + + bucketNameList = []string{ + string(bucketSubscription), + } +) + +type CacheFile struct { + path string + DB *bbolt.DB +} + +func New(path string) *CacheFile { + return &CacheFile{ + path: path, + } +} + +func (c *CacheFile) Start() error { + const fileMode = 0o666 + options := bbolt.Options{Timeout: time.Second} + var ( + db *bbolt.DB + err error + ) + for i := 0; i < 10; i++ { + db, err = bbolt.Open(c.path, fileMode, &options) + if err == nil { + break + } + if errors.Is(err, bboltErrors.ErrTimeout) { + continue + } + if E.IsMulti(err, bboltErrors.ErrInvalid, bboltErrors.ErrChecksum, bboltErrors.ErrVersionMismatch) { + rmErr := os.Remove(c.path) + if rmErr != nil { + return err + } + } + time.Sleep(100 * time.Millisecond) + } + if err != nil { + return err + } + err = db.Batch(func(tx *bbolt.Tx) error { + return tx.ForEach(func(name []byte, b *bbolt.Bucket) error { + bucketName := string(name) + if !(common.Contains(bucketNameList, bucketName)) { + _ = tx.DeleteBucket(name) + } + return nil + }) + }) + if err != nil { + db.Close() + return err + } + c.DB = db + return nil +} + +func (c *CacheFile) Close() error { + if c.DB == nil { + return nil + } + return c.DB.Close() +} + +func (c *CacheFile) LoadSubscription(name string) *Subscription { + var subscription Subscription + err := c.DB.View(func(tx *bbolt.Tx) error { + bucket := tx.Bucket(bucketSubscription) + if bucket == nil { + return nil + } + data := bucket.Get([]byte(name)) + if data == nil { + return nil + } + return subscription.UnmarshalBinary(data) + }) + if err != nil { + return nil + } + return &subscription +} + +func (c *CacheFile) StoreSubscription(name string, subscription *Subscription) error { + data, err := subscription.MarshalBinary() + if err != nil { + return err + } + return c.DB.Batch(func(tx *bbolt.Tx) error { + bucket, err := tx.CreateBucketIfNotExists(bucketSubscription) + if err != nil { + return err + } + return bucket.Put([]byte(name), data) + }) +} diff --git a/common/cachefile/subscription.go b/common/cachefile/subscription.go new file mode 100644 index 0000000..b257f37 --- /dev/null +++ b/common/cachefile/subscription.go @@ -0,0 +1,76 @@ +package cachefile + +import ( + "bytes" + "encoding/binary" + "time" + + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common/json" + "github.com/sagernet/sing/common/rw" +) + +type Subscription struct { + Content []option.Outbound + LastUpdated time.Time + LastEtag string +} + +func (c *Subscription) MarshalBinary() ([]byte, error) { + var buffer bytes.Buffer + buffer.WriteByte(1) + content, err := json.Marshal(c.Content) + if err != nil { + return nil, err + } + err = rw.WriteUVariant(&buffer, uint64(len(content))) + if err != nil { + return nil, err + } + _, err = buffer.Write(content) + if err != nil { + return nil, err + } + err = binary.Write(&buffer, binary.BigEndian, c.LastUpdated.Unix()) + if err != nil { + return nil, err + } + err = rw.WriteVString(&buffer, c.LastEtag) + if err != nil { + return nil, err + } + return buffer.Bytes(), nil +} + +func (c *Subscription) UnmarshalBinary(data []byte) error { + reader := bytes.NewReader(data) + version, err := reader.ReadByte() + if err != nil { + return err + } + _ = version + contentLength, err := rw.ReadUVariant(reader) + if err != nil { + return err + } + content := make([]byte, contentLength) + _, err = reader.Read(content) + if err != nil { + return err + } + err = json.Unmarshal(content, &c.Content) + if err != nil { + return err + } + var lastUpdatedUnix int64 + err = binary.Read(reader, binary.BigEndian, &lastUpdatedUnix) + if err != nil { + return err + } + c.LastUpdated = time.Unix(lastUpdatedUnix, 0) + c.LastEtag, err = rw.ReadVString(reader) + if err != nil { + return err + } + return nil +} diff --git a/common/metadata/metadata.go b/common/metadata/metadata.go new file mode 100644 index 0000000..4749d10 --- /dev/null +++ b/common/metadata/metadata.go @@ -0,0 +1,90 @@ +package metadata + +import ( + "strings" + + "github.com/sagernet/serenity/common/semver" + E "github.com/sagernet/sing/common/exceptions" +) + +type Platform string + +const ( + PlatformUnknown Platform = "" + PlatformAndroid Platform = "android" + PlatformiOS Platform = "ios" + PlatformMacOS Platform = "macos" + PlatformAppleTVOS Platform = "tvos" +) + +func ParsePlatform(name string) (Platform, error) { + switch strings.ToLower(name) { + case "android": + return PlatformAndroid, nil + case "ios": + return PlatformiOS, nil + case "macos": + return PlatformMacOS, nil + case "tvos": + return PlatformAppleTVOS, nil + default: + return PlatformUnknown, E.New("unknown platform: ", name) + } +} + +func (m Platform) IsApple() bool { + switch m { + case PlatformiOS, PlatformMacOS, PlatformAppleTVOS: + return true + default: + return false + } +} + +func (m Platform) IsNetworkExtensionMemoryLimited() bool { + switch m { + case PlatformiOS, PlatformAppleTVOS: + return true + default: + return false + } +} + +func (m Platform) TunOnly() bool { + return m.IsApple() +} + +func (m Platform) String() string { + return string(m) +} + +type Metadata struct { + UserAgent string + Platform Platform + Version *semver.Version +} + +func Detect(userAgent string) Metadata { + var metadata Metadata + metadata.UserAgent = userAgent + if strings.HasPrefix(userAgent, "SFA") { + metadata.Platform = PlatformAndroid + } else if strings.HasPrefix(userAgent, "SFI") { + metadata.Platform = PlatformiOS + } else if strings.HasPrefix(userAgent, "SFM") { + metadata.Platform = PlatformMacOS + } else if strings.HasPrefix(userAgent, "SFT") { + metadata.Platform = PlatformAppleTVOS + } + var versionName string + if strings.Contains(userAgent, "sing-box ") { + versionName = strings.Split(userAgent, "sing-box ")[1] + versionName = strings.Split(versionName, " ")[0] + versionName = strings.Split(versionName, ")")[0] + } + if semver.IsValid(versionName) { + version := semver.ParseVersion(versionName) + metadata.Version = &version + } + return metadata +} diff --git a/common/semver/version.go b/common/semver/version.go new file mode 100644 index 0000000..8b2c62e --- /dev/null +++ b/common/semver/version.go @@ -0,0 +1,148 @@ +package semver + +import ( + "strconv" + "strings" + + F "github.com/sagernet/sing/common/format" + + "golang.org/x/mod/semver" +) + +type Version struct { + Major int + Minor int + Patch int + Commit string + PreReleaseIdentifier string + PreReleaseVersion int +} + +func (v Version) LessThan(anotherVersion Version) bool { + return !v.GreaterThanOrEqual(anotherVersion) +} + +func (v Version) LessThanOrEqual(anotherVersion Version) bool { + return v == anotherVersion || anotherVersion.GreaterThan(v) +} + +func (v Version) GreaterThanOrEqual(anotherVersion Version) bool { + return v == anotherVersion || v.GreaterThan(anotherVersion) +} + +func (v Version) GreaterThan(anotherVersion Version) bool { + if v.Major > anotherVersion.Major { + return true + } else if v.Major < anotherVersion.Major { + return false + } + if v.Minor > anotherVersion.Minor { + return true + } else if v.Minor < anotherVersion.Minor { + return false + } + if v.Patch > anotherVersion.Patch { + return true + } else if v.Patch < anotherVersion.Patch { + return false + } + if v.PreReleaseIdentifier == "" && anotherVersion.PreReleaseIdentifier != "" { + return true + } else if v.PreReleaseIdentifier != "" && anotherVersion.PreReleaseIdentifier == "" { + return false + } + if v.PreReleaseIdentifier != "" && anotherVersion.PreReleaseIdentifier != "" { + if v.PreReleaseIdentifier == anotherVersion.PreReleaseIdentifier { + if v.PreReleaseVersion > anotherVersion.PreReleaseVersion { + return true + } else if v.PreReleaseVersion < anotherVersion.PreReleaseVersion { + return false + } + } + preReleaseIdentifier := parsePreReleaseIdentifier(v.PreReleaseIdentifier) + anotherPreReleaseIdentifier := parsePreReleaseIdentifier(anotherVersion.PreReleaseIdentifier) + if preReleaseIdentifier < anotherPreReleaseIdentifier { + return true + } else if preReleaseIdentifier > anotherPreReleaseIdentifier { + return false + } + } + return false +} + +func parsePreReleaseIdentifier(identifier string) int { + if strings.HasPrefix(identifier, "rc") { + return 1 + } else if strings.HasPrefix(identifier, "beta") { + return 2 + } else if strings.HasPrefix(identifier, "alpha") { + return 3 + } + return 0 +} + +func (v Version) String() string { + version := F.ToString(v.Major, ".", v.Minor, ".", v.Patch) + if v.PreReleaseIdentifier != "" { + version = F.ToString(version, "-", v.PreReleaseIdentifier, ".", v.PreReleaseVersion) + } + return version +} + +func (v Version) BadString() string { + version := F.ToString(v.Major, ".", v.Minor) + if v.Patch > 0 { + version = F.ToString(version, ".", v.Patch) + } + if v.PreReleaseIdentifier != "" { + version = F.ToString(version, "-", v.PreReleaseIdentifier) + if v.PreReleaseVersion > 0 { + version = F.ToString(version, v.PreReleaseVersion) + } + } + return version +} + +func IsValid(versionName string) bool { + return semver.IsValid("v" + versionName) +} + +func ParseVersion(versionName string) (version Version) { + if strings.HasPrefix(versionName, "v") { + versionName = versionName[1:] + } + if strings.Contains(versionName, "-") { + parts := strings.Split(versionName, "-") + versionName = parts[0] + identifier := parts[1] + if strings.Contains(identifier, ".") { + identifierParts := strings.Split(identifier, ".") + version.PreReleaseIdentifier = identifierParts[0] + if len(identifierParts) >= 2 { + version.PreReleaseVersion, _ = strconv.Atoi(identifierParts[1]) + } + } else { + if strings.HasPrefix(identifier, "alpha") { + version.PreReleaseIdentifier = "alpha" + version.PreReleaseVersion, _ = strconv.Atoi(identifier[5:]) + } else if strings.HasPrefix(identifier, "beta") { + version.PreReleaseIdentifier = "beta" + version.PreReleaseVersion, _ = strconv.Atoi(identifier[4:]) + } else { + version.Commit = identifier + } + } + } + versionElements := strings.Split(versionName, ".") + versionLen := len(versionElements) + if versionLen >= 1 { + version.Major, _ = strconv.Atoi(versionElements[0]) + } + if versionLen >= 2 { + version.Minor, _ = strconv.Atoi(versionElements[1]) + } + if versionLen >= 3 { + version.Patch, _ = strconv.Atoi(versionElements[2]) + } + return +} diff --git a/constant/version.go b/constant/version.go new file mode 100644 index 0000000..c177def --- /dev/null +++ b/constant/version.go @@ -0,0 +1,37 @@ +package constant + +import ( + "runtime/debug" + "sync" +) + +var ( + Version = "" + coreVersion string + initializeCoreVersionOnce sync.Once +) + +func CoreVersion() string { + initializeCoreVersionOnce.Do(initializeCoreVersion) + return coreVersion +} + +func initializeCoreVersion() { + if !initializeCoreVersion0() { + coreVersion = "unknown" + } +} + +func initializeCoreVersion0() bool { + buildInfo, loaded := debug.ReadBuildInfo() + if !loaded { + return false + } + for _, it := range buildInfo.Deps { + if it.Path == "github.com/sagernet/sing-box" { + coreVersion = it.Version + return true + } + } + return false +} diff --git a/docs/CNAME b/docs/CNAME new file mode 100644 index 0000000..818e158 --- /dev/null +++ b/docs/CNAME @@ -0,0 +1 @@ +serenity.sagernet.org \ No newline at end of file diff --git a/docs/assets/icon.svg b/docs/assets/icon.svg new file mode 100644 index 0000000..146d085 --- /dev/null +++ b/docs/assets/icon.svg @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/changelog.md b/docs/changelog.md new file mode 100644 index 0000000..fd8652d --- /dev/null +++ b/docs/changelog.md @@ -0,0 +1,7 @@ +--- +icon: material/alert-decagram +--- + +##### 2023/12/12 + +No changelog before. diff --git a/docs/configuration/index.md b/docs/configuration/index.md new file mode 100644 index 0000000..b9c3d78 --- /dev/null +++ b/docs/configuration/index.md @@ -0,0 +1,75 @@ +# Introduction + +serenity uses JSON for configuration files. + +### Structure + +```json +{ + "log": {}, + "listen": "", + "tls": {}, + "cache_file": "", + "outbounds": [], + "subscriptions": [], + "templates": [], + "profiles": [], + "users": [] +} +``` + +### Fields + +#### log + +Log configuration, see [Log](https://sing-box.sagernet.org/configuration/log/). + +#### listen + +Listen address. + +#### tls + +TLS configuration, see [TLS](https://sing-box.sagernet.org/configuration/shared/tls/#inbound). + +#### cache_file + +Cache file path. + +`cache.db` will be used if empty. + +#### outbounds + +List of [Outbound][outbound], can be referenced in [Profile](./profile). + +For chained outbounds, use an array of outbounds as an item, and the first outbound will be the entry. + +#### subscriptions + +List of [Subscription](./subscription), can be referenced in [Profile](./profile). + +#### templates + +List of [Template](./template), can be referenced in [Profile](./profile). + +#### profiles + +List of [Profile](./profile), can be referenced in [User](./user). + +#### users + +List of [User](./user). + +### Check + +```bash +serenity check +``` + +### Format + +```bash +serenity format -w -c config.json -D config_directory +``` + +[outbound]: https://sing-box.sagernet.org/configuration/outbound/ \ No newline at end of file diff --git a/docs/configuration/profile.md b/docs/configuration/profile.md new file mode 100644 index 0000000..d14c442 --- /dev/null +++ b/docs/configuration/profile.md @@ -0,0 +1,42 @@ +### Structure + +```json +{ + "name": "", + "template": "", + "template_for_platform": {}, + "template_for_user_agent": {}, + "outbound": [], + "subscription": [] +} +``` + +### Fields + +#### name + +==Required== + +Profile name. + +#### template + +Default template name. + +A empty template is used by default. + +#### template_for_platform + +Custom template for different graphical client. + +The key is one of `android`, `ios`, `macos`, `tvos`. + +The Value is the template name. + +#### template_for_user_agent + +Custom template for different user agent. + +The key is a regular expression matching the user agent. + +The value is the template name. diff --git a/docs/configuration/subscription.md b/docs/configuration/subscription.md new file mode 100644 index 0000000..b98cf2c --- /dev/null +++ b/docs/configuration/subscription.md @@ -0,0 +1,114 @@ +### Structure + +```json +{ + "name": "", + "url": "", + "user_agent": "", + "process": [ + { + "filter": [], + "exclude": [], + "filter_outbound_type": [], + "exclude_outbound_type": [], + "rename": [], + "remove_emoji": false + } + ], + "deduplication": false, + "update_interval": "5m", + "generate_selector": false, + "generate_urltest": false, + "urltest_suffix": false, + "custom_selector": {}, + "custom_urltest": {} +} +``` + +### Fields + +#### name + +==Required== + +Name of the subscription, will be used in group tags. + +#### url + +==Required== + +Subscription URL. + +#### user_agent + +User-Agent in HTTP request. + +`ClashForAndroid/serenity` is used by default. + +#### process + +!!! note "" + + You can ignore the JSON Array [] tag when the content is only one item + +Process rules. + +#### process.filter + +Regexp filter rules, non-matching outbounds will be removed. + +#### process.exclude + +Regexp exclude rules, matching outbounds will be removed. + +#### process.filter_outbound_type + +Outbound type filter rules, non-matching outbounds will be removed. + +#### process.exclude_outbound_type + +Outbound type exclude rules, matching outbounds will be removed. + +#### process.rename + +Regexp rename rules, matching outbounds will be renamed. + +#### process.remove_emoji + +Remove emojis in outbound tags. + +#### deduplication + +Remove outbounds with duplicate server destinations (Domain will be resolved to compare). + +#### update_interval + +Subscription update interval. + +`1h` is used by default. + +#### generate_selector + +Generate a global `Selector` outbound for the subscription. + +If both `generate_selector` and `generate_urltest` are disabled, subscription outbounds will be added to global groups. + +#### generate_urltest + +Generate a global `URLTest` outbound for the subscription. + +If both `generate_selector` and `generate_urltest` are disabled, subscription outbounds will be added to global groups. + +#### urltest_suffix + +Tag suffix of generated `URLTest` outbound. + +` - URLTest` is used by default. + +#### custom_selector + +Custom [Selector](https://sing-box.w.org/configuration/outbound/selector/) template. + +#### custom_urltest + +Custom [URLTest](https://sing-box.sagernet.org/configuration/outbound/urltest/) template. diff --git a/docs/configuration/template.md b/docs/configuration/template.md new file mode 100644 index 0000000..2593df7 --- /dev/null +++ b/docs/configuration/template.md @@ -0,0 +1,283 @@ +### Structure + +```json +{ + "name": "", + + // Global + + "domain_strategy": "", + "disable_traffic_bypass": false, + "disable_rule_set": false, + "remote_resolve": false, + + // DNS + + "dns_default": "", + "dns_local": "", + "enable_fakeip": false, + "pre_dns_rules": [], + "custom_dns_rules": [], + + // Inbound + + "disable_tun": false, + "disable_system_proxy": false, + "custom_tun": {}, + "custom_mixed": {}, + + // Outbound + + "extra_groups": [ + { + "tag": "", + "type": "", + "filter": "", + "exclude": "", + "custom_selector": {}, + "custom_urltest": {} + } + ], + "generate_global_urltest": false, + "direct_tag": "", + "default_tag": "", + "urltest_tag": "", + "custom_direct": {}, + "custom_selector": {}, + "custom_urltest": {}, + + // Route + + "disable_default_rules": false, + "pre_rules": [], + "custom_rules": [], + "enable_jsdelivr": false, + "custom_geoip": {}, + "custom_geosite": {}, + "custom_rule_set": [], + + // Experimental + + "disable_cache_file": false, + "disable_clash_mode": false, + "clash_mode_rule": "", + "clash_mode_global": "", + "clash_mode_direct": "", + "custom_clash_api": {}, + + // Debug + + "pprof_listen": "", + "memory_limit": "" +} +``` + +### Fields + +#### name + +==Required== + +Profile name. + +#### domain_strategy + +Global sing-box domain strategy. + +One of `prefer_ipv4` `prefer_ipv6` `ipv4_only` `ipv6_only`. + +If `*_only` enabled, TUN and DNS will be configured to disable the other network. + +Note that if want `prefer_*` to take effect on transparent proxy requests, set `enable_fakeip`. + +#### disable_rule_set + +Use `geoip` and `geosite` for traffic bypassing instead of rule sets. + +#### disable_traffic_bypass + +Disable traffic bypass for Chinese DNS queries and connections. + +#### remote_resolve + +Don't generate `doamin_strategy` options for inbounds. + +#### dns_default + +Default DNS server. + +`tls://8.8.8.8` is used by default. + +#### dns_local + +DNS server used for China DNS requests. + +`114.114.114.114` is used by default. + +#### enable_fakeip + +Enable FakeIP. + +#### pre_dns_rules + +List of [DNS Rule](https://sing-box.sagernet.org/configuration/dns/rule/). + +Will be applied before traffic bypassing rules. + +#### custom_dns_rules + +List of [DNS Rule](https://sing-box.sagernet.org/configuration/dns/rule/). + +No default traffic bypassing DNS rules will be generated if not empty. + +#### disable_tun + +Don't generate TUN inbound. + +If the target platform can only use TUN for proxy (currently all Apple platforms), this item will not take effect. + +#### disable_system_proxy + +Don't generate `tun.platform.http_proxy` for known platforms and `set_system_proxy` for unknown platforms. + +#### custom_tun + +Custom [TUN](https://sing-box.sagernet.org/configuration/inbound/tun/) inbound template. + +#### custom_mixed + +Custom [Mixed](https://sing-box.sagernet.org/configuration/inbound/mixed/) inbound template. + +#### extra_groups + +Generate extra outbound groups. + +#### extra_groups.tag + +==Required== + +Tag of the group outbound. + +#### extra_groups.type + +==Required== + +Type of the group outbound. + +#### extra_groups.filter + +Regexp filter rules, non-matching outbounds will be removed. + +#### extra_groups.exclude + +Regexp exclude rules, matching outbounds will be removed. + +#### extra_groups.custom_selector + +Custom [Selector](https://sing-box.sagernet.org/configuration/outbound/selector/) template. + +#### extra_groups.custom_urltest + +Custom [URLTest](https://sing-box.sagernet.org/configuration/outbound/urltest/) template. + +#### generate_global_urltest + +Generate a global `URLTest` outbound with all global outbounds. + +#### direct_tag + +Custom direct outbound tag. + +#### default_tag + +Custom default outbound tag. + +#### urltest_tag + +Custom URLTest outbound tag. + +#### custom_direct + +Custom [Direct](https://sing-box.sagernet.org/configuration/outbound/direct/) outbound template. + +#### custom_selector + +Custom [Selector](https://sing-box.sagernet.org/configuration/outbound/selector/) outbound template. + +#### custom_urltest + +Custom [URLTest](https://sing-box.sagernet.org/configuration/outbound/urltest/) outbound template. + +#### disable_default_rules + +Don't generate some useful rules. + +#### pre_rules + +List of [Rule](https://sing-box.sagernet.org/configuration/route/rule/). + +Will be applied before traffic bypassing rules. + +#### custom_rules + +List of [Rule](https://sing-box.sagernet.org/configuration/route/rule/). + +No default traffic bypassing rules will be generated if not empty. + +#### enable_jsdelivr + +Use jsDelivr CDN and direct outbound for default rule sets or Geo resources. + +#### custom_geoip + +Custom [GeoIP](https://sing-box.sagernet.org/configuration/route/geoip/) template. + +#### custom_geosite + +Custom [GeoSite](https://sing-box.sagernet.org/configuration/route/geosite/) template. + +#### custom_rule_set + +List of [RuleSet](https://sing-box.sagernet.org/configuration/rule-set/). + +Default rule sets will not be generated if not empty. + +#### disable_cache_file + +Don't generate `cache_file` related options. + +#### disable_clash_mode + +Don't generate `clash_mode` related options. + +#### clash_mode_rule + +Name of the 'Rule' Clash mode. + +`Rule` is used by default. + +#### clash_mode_global + +Name of the 'Global' Clash mode. + +`Global` is used by default. + +#### clash_mode_direct + +Name of the 'Direct' Clash mode. + +`Direct` is used by default. + +#### custom_clash_api + +Custom [Clash API](https://sing-box.sagernet.org/configuration/experimental/clash-api/) template. + +#### pprof_listen + +Listen address of the pprof server. + +#### memory_limit + +Set soft memory limit for sing-box. + +`100m` is recommended if memory limit is required. diff --git a/docs/configuration/user.md b/docs/configuration/user.md new file mode 100644 index 0000000..32c2d24 --- /dev/null +++ b/docs/configuration/user.md @@ -0,0 +1,36 @@ +### Structure + +```json +{ + "name": "", + "password": "", + "profile": [], + "default_profile": "" +} +``` + +!!! note "" + + You can ignore the JSON Array [] tag when the content is only one item + +### Fields + +#### name + +HTTP basic authentication username. + +#### password + +HTTP basic authentication password. + +#### profile + +Accessible profiles for this user. + +List of [Profile](./profile) name. + +#### default_profile + +Default profile name. + +First profile is used by default. diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..fc98109 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,31 @@ +--- +description: Welcome to the wiki page for the serenity project. +--- + +# :material-home: Home + +Welcome to the wiki page for the serenity project. + +The configuration generator for sing-box. + +## License + +``` +Copyright (C) 2022 by nekohasekai + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . + +In addition, no derivative work may use the name or imply association +with this application without prior consent. +``` diff --git a/docs/index.zh.md b/docs/index.zh.md new file mode 100644 index 0000000..54060c0 --- /dev/null +++ b/docs/index.zh.md @@ -0,0 +1,31 @@ +--- +description: 欢迎来到该 sing-box 项目的文档页。 +--- + +# :material-home: 开始 + +欢迎来到该 sing-box 项目的文档页。 + +sing-box 配置生成器。 + +## 授权 + +``` +Copyright (C) 2022 by nekohasekai + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . + +In addition, no derivative work may use the name or imply association +with this application without prior consent. +``` diff --git a/docs/installation/build-from-source.md b/docs/installation/build-from-source.md new file mode 100644 index 0000000..b409fc0 --- /dev/null +++ b/docs/installation/build-from-source.md @@ -0,0 +1,23 @@ +--- +icon: material/file-code +--- + +# Build from source + +## :material-graph: Requirements + +* Go 1.21.x + +You can download and install Go from: https://go.dev/doc/install, latest version is recommended. + +## :material-fast-forward: Build + +```bash +make +``` + +Or build and install binary to `$GOBIN`: + +```bash +make install +``` diff --git a/docs/installation/build-from-source.zh.md b/docs/installation/build-from-source.zh.md new file mode 100644 index 0000000..c03ae6c --- /dev/null +++ b/docs/installation/build-from-source.zh.md @@ -0,0 +1,23 @@ +--- +icon: material/file-code +--- + +# 从源代码构建 + +## :material-graph: 要求 + +* Go 1.21.x + +您可以从 https://go.dev/doc/install 下载并安装 Go,推荐使用最新版本。 + +## :material-fast-forward: 构建 + +```bash +make +``` + +或者构建二进制文件并将其安装到 `$GOBIN`: + +```bash +make install +``` \ No newline at end of file diff --git a/docs/installation/docker.md b/docs/installation/docker.md new file mode 100644 index 0000000..0646532 --- /dev/null +++ b/docs/installation/docker.md @@ -0,0 +1,31 @@ +--- +icon: material/docker +--- + +# Docker + +## :material-console: Command + +```bash +docker run -d \ + -v /etc/serenity:/etc/serenity/ \ + --name=serenity \ + --restart=always \ + ghcr.io/sagernet/serenity \ + -D /var/lib/serenity \ + -C /etc/serenity/ run +``` + +## :material-box-shadow: Compose + +```yaml +version: "3.8" +services: + serenity: + image: ghcr.io/sagernet/serenity + container_name: serenity + restart: always + volumes: + - /etc/serenity:/etc/serenity/ + command: -D /var/lib/serenity -C /etc/serenity/ run +``` diff --git a/docs/installation/docker.zh.md b/docs/installation/docker.zh.md new file mode 100644 index 0000000..59c9af2 --- /dev/null +++ b/docs/installation/docker.zh.md @@ -0,0 +1,31 @@ +--- +icon: material/docker +--- + +# Docker + +## :material-console: 命令 + +```bash +docker run -d \ + -v /etc/serenity:/etc/serenity/ \ + --name=serenity \ + --restart=always \ + ghcr.io/sagernet/serenity \ + -D /var/lib/serenity \ + -C /etc/serenity/ run +``` + +## :material-box-shadow: Compose + +```yaml +version: "3.8" +services: + serenity: + image: ghcr.io/sagernet/serenity + container_name: serenity + restart: always + volumes: + - /etc/serenity:/etc/serenity/ + command: -D /var/lib/serenity -C /etc/serenity/ run +``` diff --git a/docs/installation/package-manager.md b/docs/installation/package-manager.md new file mode 100644 index 0000000..992fd44 --- /dev/null +++ b/docs/installation/package-manager.md @@ -0,0 +1,43 @@ +--- +icon: material/package +--- + +# Package Manager + +## :material-download-box: Manual Installation + +=== ":material-debian: Debian / DEB" + + ```bash + bash <(curl -fsSL https://sing-box.app/serenity/deb-install.sh) + ``` + +=== ":material-redhat: Redhat / RPM" + + ```bash + bash <(curl -fsSL https://sing-box.app/serenity/rpm-install.sh) + ``` + +=== ":simple-archlinux: Archlinux / PKG" + + ```bash + bash <(curl -fsSL https://sing-box.app/serenity/arch-install.sh) + ``` + +## :material-book-multiple: Service Management + +For Linux systems with [systemd][systemd], usually the installation already includes a serenity service, +you can manage the service using the following command: + +| Operation | Command | +|-----------|-----------------------------------------------| +| Enable | `sudo systemctl enable serenity` | +| Disable | `sudo systemctl disable serenity` | +| Start | `sudo systemctl start serenity` | +| Stop | `sudo systemctl stop serenity` | +| Kill | `sudo systemctl kill serenity` | +| Restart | `sudo systemctl restart serenity` | +| Logs | `sudo journalctl -u serenity --output cat -e` | +| New Logs | `sudo journalctl -u serenity --output cat -f` | + +[systemd]: https://systemd.io/ \ No newline at end of file diff --git a/docs/installation/package-manager.zh.md b/docs/installation/package-manager.zh.md new file mode 100644 index 0000000..736db6c --- /dev/null +++ b/docs/installation/package-manager.zh.md @@ -0,0 +1,43 @@ +--- +icon: material/package +--- + +# 包管理器 + +## :material-download-box: 手动安装 + +=== ":material-debian: Debian / DEB" + + ```bash + bash <(curl -fsSL https://sing-box.app/serenity/deb-install.sh) + ``` + +=== ":material-redhat: Redhat / RPM" + + ```bash + bash <(curl -fsSL https://sing-box.app/serenity/rpm-install.sh) + ``` + +=== ":simple-archlinux: Archlinux / PKG" + + ```bash + bash <(curl -fsSL https://sing-box.app/serenity/arch-install.sh) + ``` + +## :material-book-multiple: 服务管理 + +对于带有 [systemd][systemd] 的 Linux 系统,通常安装已经包含 serenity 服务, +您可以使用以下命令管理服务: + +| 行动 | 命令 | +|------|-----------------------------------------------| +| 启用 | `sudo systemctl enable serenity` | +| 禁用 | `sudo systemctl disable serenity` | +| 启动 | `sudo systemctl start serenity` | +| 停止 | `sudo systemctl stop serenity` | +| 强行停止 | `sudo systemctl kill serenity` | +| 重新启动 | `sudo systemctl restart serenity` | +| 查看日志 | `sudo journalctl -u serenity --output cat -e` | +| 实时日志 | `sudo journalctl -u serenity --output cat -f` | + +[systemd]: https://systemd.io/ \ No newline at end of file diff --git a/docs/installation/scripts/arch-install.sh b/docs/installation/scripts/arch-install.sh new file mode 100644 index 0000000..a8a30cb --- /dev/null +++ b/docs/installation/scripts/arch-install.sh @@ -0,0 +1,22 @@ +#!/bin/bash + +set -e -o pipefail + +ARCH_RAW=$(uname -m) +case "${ARCH_RAW}" in + 'x86_64') ARCH='amd64';; + 'x86' | 'i686' | 'i386') ARCH='386';; + 'aarch64' | 'arm64') ARCH='arm64';; + 'armv7l') ARCH='armv7';; + 's390x') ARCH='s390x';; + *) echo "Unsupported architecture: ${ARCH_RAW}"; exit 1;; +esac + +VERSION=$(curl -s https://api.github.com/repos/SagerNet/serenity/releases/latest \ + | grep tag_name \ + | cut -d ":" -f2 \ + | sed 's/\"//g;s/\,//g;s/\ //g;s/v//') + +curl -Lo serenity.pkg.tar.zst "https://github.com/SagerNet/serenity/releases/download/v${VERSION}/serenity_${VERSION}_linux_${ARCH}.pkg.tar.zst" +sudo pacman -U serenity.pkg.tar.zst +rm serenity.pkg.tar.zst diff --git a/docs/installation/scripts/deb-install.sh b/docs/installation/scripts/deb-install.sh new file mode 100644 index 0000000..60eadcc --- /dev/null +++ b/docs/installation/scripts/deb-install.sh @@ -0,0 +1,23 @@ +#!/bin/bash + +set -e -o pipefail + +ARCH_RAW=$(uname -m) +case "${ARCH_RAW}" in + 'x86_64') ARCH='amd64';; + 'x86' | 'i686' | 'i386') ARCH='386';; + 'aarch64' | 'arm64') ARCH='arm64';; + 'armv7l') ARCH='armv7';; + 's390x') ARCH='s390x';; + *) echo "Unsupported architecture: ${ARCH_RAW}"; exit 1;; +esac + +VERSION=$(curl -s https://api.github.com/repos/SagerNet/serenity/releases/latest \ + | grep tag_name \ + | cut -d ":" -f2 \ + | sed 's/\"//g;s/\,//g;s/\ //g;s/v//') + +curl -Lo serenity.deb "https://github.com/SagerNet/serenity/releases/download/v${VERSION}/serenity_${VERSION}_linux_${ARCH}.deb" +sudo dpkg -i serenity.deb +rm serenity.deb + diff --git a/docs/installation/scripts/rpm-install.sh b/docs/installation/scripts/rpm-install.sh new file mode 100644 index 0000000..a08c0f0 --- /dev/null +++ b/docs/installation/scripts/rpm-install.sh @@ -0,0 +1,22 @@ +#!/bin/bash + +set -e -o pipefail + +ARCH_RAW=$(uname -m) +case "${ARCH_RAW}" in + 'x86_64') ARCH='amd64';; + 'x86' | 'i686' | 'i386') ARCH='386';; + 'aarch64' | 'arm64') ARCH='arm64';; + 'armv7l') ARCH='armv7';; + 's390x') ARCH='s390x';; + *) echo "Unsupported architecture: ${ARCH_RAW}"; exit 1;; +esac + +VERSION=$(curl -s https://api.github.com/repos/SagerNet/serenity/releases/latest \ + | grep tag_name \ + | cut -d ":" -f2 \ + | sed 's/\"//g;s/\,//g;s/\ //g;s/v//') + +curl -Lo serenity.rpm "https://github.com/SagerNet/serenity/releases/download/v${VERSION}/serenity_${VERSION}_linux_${ARCH}.rpm" +sudo rpm -i serenity.rpm +rm serenity.rpm diff --git a/docs/support.md b/docs/support.md new file mode 100644 index 0000000..ed063d1 --- /dev/null +++ b/docs/support.md @@ -0,0 +1,13 @@ +--- +icon: material/forum +--- + +# Support + +| Channel | Link | +|:------------------------------|:--------------------------------------------| +| Community | https://community.sagernet.org | +| GitHub Issues | https://github.com/SagerNet/serenity/issues | +| Telegram notification channel | https://t.me/yapnc | +| Telegram user group | https://t.me/yapug | +| Email | contact@sagernet.org | diff --git a/docs/support.zh.md b/docs/support.zh.md new file mode 100644 index 0000000..8d50c30 --- /dev/null +++ b/docs/support.zh.md @@ -0,0 +1,14 @@ +--- +icon: material/forum +--- + +# 支持 + +| 通道 | 链接 | +|:--------------|:--------------------------------------------| +| 社区 | https://community.sagernet.org | +| GitHub Issues | https://github.com/SagerNet/serenity/issues | +| Telegram 通知频道 | https://t.me/yapnc | +| Telegram 用户组 | https://t.me/yapug | +| 邮件 | contact@sagernet.org | + diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..cde4e95 --- /dev/null +++ b/go.mod @@ -0,0 +1,102 @@ +module github.com/sagernet/serenity + +go 1.21 + +require ( + github.com/Dreamacro/clash v1.18.0 + github.com/go-chi/chi/v5 v5.0.11 + github.com/go-chi/cors v1.2.1 + github.com/go-chi/render v1.0.3 + github.com/miekg/dns v1.1.57 + github.com/sagernet/bbolt v0.0.0-20231014093535-ea5cb2fe9f0a + github.com/sagernet/sing v0.3.0-rc.5 + github.com/sagernet/sing-box v1.8.0-rc.4 + github.com/sagernet/sing-dns v0.1.12 + github.com/spf13/cobra v1.8.0 + golang.org/x/mod v0.14.0 + golang.org/x/net v0.19.0 +) + +require ( + berty.tech/go-libtor v1.0.385 // indirect + github.com/Dreamacro/protobytes v0.0.0-20230617041236-6500a9f4f158 // indirect + github.com/ajg/form v1.5.1 // indirect + github.com/andybalholm/brotli v1.0.6 // indirect + github.com/caddyserver/certmagic v0.20.0 // indirect + github.com/cloudflare/circl v1.3.6 // indirect + github.com/cretz/bine v0.2.0 // indirect + github.com/dlclark/regexp2 v1.10.0 // indirect + github.com/fsnotify/fsnotify v1.7.0 // indirect + github.com/gaukas/godicttls v0.0.4 // indirect + github.com/go-ole/go-ole v1.3.0 // indirect + github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 // indirect + github.com/gobwas/httphead v0.1.0 // indirect + github.com/gobwas/pool v0.2.1 // indirect + github.com/gofrs/uuid/v5 v5.0.0 // indirect + github.com/golang/protobuf v1.5.3 // indirect + github.com/google/btree v1.1.2 // indirect + github.com/google/go-cmp v0.5.9 // indirect + github.com/google/pprof v0.0.0-20231101202521-4ca4178f5c7a // indirect + github.com/gorilla/websocket v1.5.0 // indirect + github.com/hashicorp/yamux v0.1.1 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/insomniacslk/dhcp v0.0.0-20231206064809-8c70d406f6d2 // indirect + github.com/josharian/native v1.1.0 // indirect + github.com/klauspost/compress v1.17.4 // indirect + github.com/klauspost/cpuid/v2 v2.2.5 // indirect + github.com/libdns/alidns v1.0.3 // indirect + github.com/libdns/cloudflare v0.1.0 // indirect + github.com/libdns/libdns v0.2.1 // indirect + github.com/logrusorgru/aurora v2.0.3+incompatible // indirect + github.com/mdlayher/netlink v1.7.2 // indirect + github.com/mdlayher/socket v0.4.1 // indirect + github.com/mholt/acmez v1.2.0 // indirect + github.com/onsi/ginkgo/v2 v2.9.7 // indirect + github.com/ooni/go-libtor v1.1.8 // indirect + github.com/oschwald/geoip2-golang v1.9.0 // indirect + github.com/oschwald/maxminddb-golang v1.12.0 // indirect + github.com/pierrec/lz4/v4 v4.1.14 // indirect + github.com/quic-go/qpack v0.4.0 // indirect + github.com/quic-go/qtls-go1-20 v0.4.1 // indirect + github.com/sagernet/cloudflare-tls v0.0.0-20231208171750-a4483c1b7cd1 // indirect + github.com/sagernet/gvisor v0.0.0-20231209105102-8d27a30e436e // indirect + github.com/sagernet/netlink v0.0.0-20220905062125-8043b4a9aa97 // indirect + github.com/sagernet/quic-go v0.40.0 // indirect + github.com/sagernet/reality v0.0.0-20230406110435-ee17307e7691 // indirect + github.com/sagernet/sing-mux v0.1.7-rc.1 // indirect + github.com/sagernet/sing-quic v0.1.7-rc.1 // indirect + github.com/sagernet/sing-shadowsocks2 v0.1.6-rc.1 // indirect + github.com/sagernet/sing-shadowtls v0.1.4 // indirect + github.com/sagernet/sing-tun v0.2.0-rc.1 // indirect + github.com/sagernet/sing-vmess v0.1.8 // indirect + github.com/sagernet/smux v0.0.0-20231208180855-7041f6ea79e7 // indirect + github.com/sagernet/tfo-go v0.0.0-20231209031829-7b5343ac1dc6 // indirect + github.com/sagernet/utls v1.5.4 // indirect + github.com/sagernet/wireguard-go v0.0.0-20231215174105-89dec3b2f3e8 // indirect + github.com/sagernet/ws v0.0.0-20231204124109-acfe8907c854 // indirect + github.com/samber/lo v1.38.1 // indirect + github.com/scjalliance/comshim v0.0.0-20230315213746-5e51f40bd3b9 // indirect + github.com/sirupsen/logrus v1.9.3 // indirect + github.com/spf13/pflag v1.0.5 // indirect + github.com/u-root/uio v0.0.0-20230220225925-ffce2a382923 // indirect + github.com/vishvananda/netlink v1.2.1-beta.2.0.20230420174744-55c8b9515a01 // indirect + github.com/vishvananda/netns v0.0.0-20211101163701-50045581ed74 // indirect + github.com/zeebo/blake3 v0.2.3 // indirect + go.etcd.io/bbolt v1.3.7 // indirect + go.uber.org/atomic v1.11.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.uber.org/zap v1.26.0 // indirect + go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect + golang.org/x/crypto v0.17.0 // indirect + golang.org/x/exp v0.0.0-20231219180239-dc181d75b848 // indirect + golang.org/x/sync v0.5.0 // indirect + golang.org/x/sys v0.15.0 // indirect + golang.org/x/text v0.14.0 // indirect + golang.org/x/time v0.5.0 // indirect + golang.org/x/tools v0.16.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20230822172742-b8732ec3820d // indirect + google.golang.org/grpc v1.59.0 // indirect + google.golang.org/protobuf v1.31.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + lukechampine.com/blake3 v1.2.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..fdbbc45 --- /dev/null +++ b/go.sum @@ -0,0 +1,249 @@ +berty.tech/go-libtor v1.0.385 h1:RWK94C3hZj6Z2GdvePpHJLnWYobFr3bY/OdUJ5aoEXw= +berty.tech/go-libtor v1.0.385/go.mod h1:9swOOQVb+kmvuAlsgWUK/4c52pm69AdbJsxLzk+fJEw= +github.com/Dreamacro/clash v1.18.0 h1:tic7ykTOCaT0mxwAkXo6QP3LN3Nps8oZz9atgr6TU8A= +github.com/Dreamacro/clash v1.18.0/go.mod h1:r//xe/2pA3Zl+3fjIiI/o6RjIVd+z87drCD58dpRnFg= +github.com/Dreamacro/protobytes v0.0.0-20230617041236-6500a9f4f158 h1:JFnwKplz9hj8ubqYjm8HkgZS1Rvz9yW+u/XCNNTxr0k= +github.com/Dreamacro/protobytes v0.0.0-20230617041236-6500a9f4f158/go.mod h1:QvmEZ/h6KXszPOr2wUFl7Zn3hfFNYdfbXwPVDTyZs6k= +github.com/ajg/form v1.5.1 h1:t9c7v8JUKu/XxOGBU0yjNpaMloxGEJhUkqFRq0ibGeU= +github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY= +github.com/andybalholm/brotli v1.0.6 h1:Yf9fFpf49Zrxb9NlQaluyE92/+X7UVHlhMNJN2sxfOI= +github.com/andybalholm/brotli v1.0.6/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= +github.com/caddyserver/certmagic v0.20.0 h1:bTw7LcEZAh9ucYCRXyCpIrSAGplplI0vGYJ4BpCQ/Fc= +github.com/caddyserver/certmagic v0.20.0/go.mod h1:N4sXgpICQUskEWpj7zVzvWD41p3NYacrNoZYiRM2jTg= +github.com/cloudflare/circl v1.3.6 h1:/xbKIqSHbZXHwkhbrhrt2YOHIwYJlXH94E3tI/gDlUg= +github.com/cloudflare/circl v1.3.6/go.mod h1:5XYMA4rFBvNIrhs50XuiBJ15vF2pZn4nnUKZrLbUZFA= +github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/cretz/bine v0.1.0/go.mod h1:6PF6fWAvYtwjRGkAuDEJeWNOv3a2hUouSP/yRYXmvHw= +github.com/cretz/bine v0.2.0 h1:8GiDRGlTgz+o8H9DSnsl+5MeBK4HsExxgl6WgzOCuZo= +github.com/cretz/bine v0.2.0/go.mod h1:WU4o9QR9wWp8AVKtTM1XD5vUHkEqnf2vVSo6dBqbetI= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dlclark/regexp2 v1.10.0 h1:+/GIL799phkJqYW+3YbOd8LCcbHzT0Pbo8zl70MHsq0= +github.com/dlclark/regexp2 v1.10.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= +github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= +github.com/gaukas/godicttls v0.0.4 h1:NlRaXb3J6hAnTmWdsEKb9bcSBD6BvcIjdGdeb0zfXbk= +github.com/gaukas/godicttls v0.0.4/go.mod h1:l6EenT4TLWgTdwslVb4sEMOCf7Bv0JAK67deKr9/NCI= +github.com/go-chi/chi/v5 v5.0.11 h1:BnpYbFZ3T3S1WMpD79r7R5ThWX40TaFB7L31Y8xqSwA= +github.com/go-chi/chi/v5 v5.0.11/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= +github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4= +github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= +github.com/go-chi/render v1.0.3 h1:AsXqd2a1/INaIfUSKq3G5uA8weYx20FOsM7uSoCyyt4= +github.com/go-chi/render v1.0.3/go.mod h1:/gr3hVkmYR0YlEy3LxCuVRFzEu9Ruok+gFqbIofjao0= +github.com/go-logr/logr v1.2.4 h1:g01GSCwiDw2xSZfjJ2/T9M+S6pFdcNtFYsp+Y43HYDQ= +github.com/go-logr/logr v1.2.4/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= +github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= +github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI= +github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls= +github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU= +github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM= +github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og= +github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= +github.com/gofrs/uuid/v5 v5.0.0 h1:p544++a97kEL+svbcFbCQVM9KFu0Yo25UoISXGNNH9M= +github.com/gofrs/uuid/v5 v5.0.0/go.mod h1:CDOjlDMVAtN56jqyRUZh58JT31Tiw7/oQyEXZV+9bD8= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= +github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/google/btree v1.1.2 h1:xf4v41cLI2Z6FxbKm+8Bu+m8ifhj15JuZ9sa0jZCMUU= +github.com/google/btree v1.1.2/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/pprof v0.0.0-20231101202521-4ca4178f5c7a h1:fEBsGL/sjAuJrgah5XqmmYsTLzJp/TO9Lhy39gkverk= +github.com/google/pprof v0.0.0-20231101202521-4ca4178f5c7a/go.mod h1:czg5+yv1E0ZGTi6S6vVK1mke0fV+FaUhNGcd6VRS9Ik= +github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= +github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/hashicorp/yamux v0.1.1 h1:yrQxtgseBDrq9Y652vSRDvsKCJKOUD+GzTS4Y0Y8pvE= +github.com/hashicorp/yamux v0.1.1/go.mod h1:CtWFDAQgb7dxtzFs4tWbplKIe2jSi3+5vKbgIO0SLnQ= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/insomniacslk/dhcp v0.0.0-20231206064809-8c70d406f6d2 h1:9K06NfxkBh25x56yVhWWlKFE8YpicaSfHwoV8SFbueA= +github.com/insomniacslk/dhcp v0.0.0-20231206064809-8c70d406f6d2/go.mod h1:3A9PQ1cunSDF/1rbTq99Ts4pVnycWg+vlPkfeD2NLFI= +github.com/josharian/native v1.0.1-0.20221213033349-c1e37c09b531/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w= +github.com/josharian/native v1.1.0 h1:uuaP0hAbW7Y4l0ZRQ6C9zfb7Mg1mbFKry/xzDAfmtLA= +github.com/josharian/native v1.1.0/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w= +github.com/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4= +github.com/klauspost/compress v1.17.4/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM= +github.com/klauspost/cpuid/v2 v2.0.12/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c= +github.com/klauspost/cpuid/v2 v2.2.5 h1:0E5MSMDEoAulmXNFquVs//DdoomxaoTY1kUhbc/qbZg= +github.com/klauspost/cpuid/v2 v2.2.5/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/libdns/alidns v1.0.3 h1:LFHuGnbseq5+HCeGa1aW8awyX/4M2psB9962fdD2+yQ= +github.com/libdns/alidns v1.0.3/go.mod h1:e18uAG6GanfRhcJj6/tps2rCMzQJaYVcGKT+ELjdjGE= +github.com/libdns/cloudflare v0.1.0 h1:93WkJaGaiXCe353LHEP36kAWCUw0YjFqwhkBkU2/iic= +github.com/libdns/cloudflare v0.1.0/go.mod h1:a44IP6J1YH6nvcNl1PverfJviADgXUnsozR3a7vBKN8= +github.com/libdns/libdns v0.2.0/go.mod h1:yQCXzk1lEZmmCPa857bnk4TsOiqYasqpyOEeSObbb40= +github.com/libdns/libdns v0.2.1 h1:Wu59T7wSHRgtA0cfxC+n1c/e+O3upJGWytknkmFEDis= +github.com/libdns/libdns v0.2.1/go.mod h1:yQCXzk1lEZmmCPa857bnk4TsOiqYasqpyOEeSObbb40= +github.com/logrusorgru/aurora v2.0.3+incompatible h1:tOpm7WcpBTn4fjmVfgpQq0EfczGlG91VSDkswnjF5A8= +github.com/logrusorgru/aurora v2.0.3+incompatible/go.mod h1:7rIyQOR62GCctdiQpZ/zOJlFyk6y+94wXzv6RNZgaR4= +github.com/mdlayher/netlink v1.7.2 h1:/UtM3ofJap7Vl4QWCPDGXY8d3GIY2UGSDbK+QWmY8/g= +github.com/mdlayher/netlink v1.7.2/go.mod h1:xraEF7uJbxLhc5fpHL4cPe221LI2bdttWlU+ZGLfQSw= +github.com/mdlayher/socket v0.4.1 h1:eM9y2/jlbs1M615oshPQOHZzj6R6wMT7bX5NPiQvn2U= +github.com/mdlayher/socket v0.4.1/go.mod h1:cAqeGjoufqdxWkD7DkpyS+wcefOtmu5OQ8KuoJGIReA= +github.com/mholt/acmez v1.2.0 h1:1hhLxSgY5FvH5HCnGUuwbKY2VQVo8IU7rxXKSnZ7F30= +github.com/mholt/acmez v1.2.0/go.mod h1:VT9YwH1xgNX1kmYY89gY8xPJC84BFAisjo8Egigt4kE= +github.com/miekg/dns v1.1.57 h1:Jzi7ApEIzwEPLHWRcafCN9LZSBbqQpxjt/wpgvg7wcM= +github.com/miekg/dns v1.1.57/go.mod h1:uqRjCRUuEAA6qsOiJvDd+CFo/vW+y5WR6SNmHE55hZk= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= +github.com/onsi/ginkgo/v2 v2.9.7 h1:06xGQy5www2oN160RtEZoTvnP2sPhEfePYmCDc2szss= +github.com/onsi/ginkgo/v2 v2.9.7/go.mod h1:cxrmXWykAwTwhQsJOPfdIDiJ+l2RYq7U8hFU+M/1uw0= +github.com/onsi/gomega v1.27.7 h1:fVih9JD6ogIiHUN6ePK7HJidyEDpWGVB5mzM7cWNXoU= +github.com/onsi/gomega v1.27.7/go.mod h1:1p8OOlwo2iUUDsHnOrjE5UKYJ+e3W8eQ3qSlRahPmr4= +github.com/ooni/go-libtor v1.1.8 h1:Wo3V3DVTxl5vZdxtQakqYP+DAHx7pPtAFSl1bnAa08w= +github.com/ooni/go-libtor v1.1.8/go.mod h1:q1YyLwRD9GeMyeerVvwc0vJ2YgwDLTp2bdVcrh/JXyI= +github.com/oschwald/geoip2-golang v1.9.0 h1:uvD3O6fXAXs+usU+UGExshpdP13GAqp4GBrzN7IgKZc= +github.com/oschwald/geoip2-golang v1.9.0/go.mod h1:BHK6TvDyATVQhKNbQBdrj9eAvuwOMi2zSFXizL3K81Y= +github.com/oschwald/maxminddb-golang v1.12.0 h1:9FnTOD0YOhP7DGxGsq4glzpGy5+w7pq50AS6wALUMYs= +github.com/oschwald/maxminddb-golang v1.12.0/go.mod h1:q0Nob5lTCqyQ8WT6FYgS1L7PXKVVbgiymefNwIjPzgY= +github.com/pierrec/lz4/v4 v4.1.14 h1:+fL8AQEZtz/ijeNnpduH0bROTu0O3NZAlPjQxGn8LwE= +github.com/pierrec/lz4/v4 v4.1.14/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/quic-go/qpack v0.4.0 h1:Cr9BXA1sQS2SmDUWjSofMPNKmvF6IiIfDRmgU0w1ZCo= +github.com/quic-go/qpack v0.4.0/go.mod h1:UZVnYIfi5GRk+zI9UMaCPsmZ2xKJP7XBUvVyT1Knj9A= +github.com/quic-go/qtls-go1-20 v0.4.1 h1:D33340mCNDAIKBqXuAvexTNMUByrYmFYVfKfDN5nfFs= +github.com/quic-go/qtls-go1-20 v0.4.1/go.mod h1:X9Nh97ZL80Z+bX/gUXMbipO6OxdiDi58b/fMC9mAL+k= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sagernet/bbolt v0.0.0-20231014093535-ea5cb2fe9f0a h1:+NkI2670SQpQWvkkD2QgdTuzQG263YZ+2emfpeyGqW0= +github.com/sagernet/bbolt v0.0.0-20231014093535-ea5cb2fe9f0a/go.mod h1:63s7jpZqcDAIpj8oI/1v4Izok+npJOHACFCU6+huCkM= +github.com/sagernet/cloudflare-tls v0.0.0-20231208171750-a4483c1b7cd1 h1:YbmpqPQEMdlk9oFSKYWRqVuu9qzNiOayIonKmv1gCXY= +github.com/sagernet/cloudflare-tls v0.0.0-20231208171750-a4483c1b7cd1/go.mod h1:J2yAxTFPDjrDPhuAi9aWFz2L3ox9it4qAluBBbN0H5k= +github.com/sagernet/gvisor v0.0.0-20231209105102-8d27a30e436e h1:DOkjByVeAR56dkszjnMZke4wr7yM/1xHaJF3G9olkEE= +github.com/sagernet/gvisor v0.0.0-20231209105102-8d27a30e436e/go.mod h1:fLxq/gtp0qzkaEwywlRRiGmjOK5ES/xUzyIKIFP2Asw= +github.com/sagernet/netlink v0.0.0-20220905062125-8043b4a9aa97 h1:iL5gZI3uFp0X6EslacyapiRz7LLSJyr4RajF/BhMVyE= +github.com/sagernet/netlink v0.0.0-20220905062125-8043b4a9aa97/go.mod h1:xLnfdiJbSp8rNqYEdIW/6eDO4mVoogml14Bh2hSiFpM= +github.com/sagernet/quic-go v0.40.0 h1:DvQNPb72lzvNQDe9tcUyHTw8eRv6PLtM2mNYmdlzUMo= +github.com/sagernet/quic-go v0.40.0/go.mod h1:VqtdhlbkeeG5Okhb3eDMb/9o0EoglReHunNT9ukrJAI= +github.com/sagernet/reality v0.0.0-20230406110435-ee17307e7691 h1:5Th31OC6yj8byLGkEnIYp6grlXfo1QYUfiYFGjewIdc= +github.com/sagernet/reality v0.0.0-20230406110435-ee17307e7691/go.mod h1:B8lp4WkQ1PwNnrVMM6KyuFR20pU8jYBD+A4EhJovEXU= +github.com/sagernet/sing v0.2.18/go.mod h1:OL6k2F0vHmEzXz2KW19qQzu172FDgSbUSODylighuVo= +github.com/sagernet/sing v0.3.0-rc.5 h1:dliXrWhkeI1ppcwTfwDPktLf75DXQLbd9g5ngtAEmc0= +github.com/sagernet/sing v0.3.0-rc.5/go.mod h1:9pfuAH6mZfgnz/YjP6xu5sxx882rfyjpcrTdUpd6w3g= +github.com/sagernet/sing-box v1.8.0-rc.4 h1:DJdSlBFnc/7RQDRWjZBRrOWXro1+TMbyohd57smdgZo= +github.com/sagernet/sing-box v1.8.0-rc.4/go.mod h1:RXQ/HtakATVTLpEpVnrpn39gGCAm9SnNYJRb7hqDnS0= +github.com/sagernet/sing-dns v0.1.12 h1:1HqZ+ln+Rezx/aJMStaS0d7oPeX2EobSV1NT537kyj4= +github.com/sagernet/sing-dns v0.1.12/go.mod h1:rx/DTOisneQpCgNQ4jbFU/JNEtnz0lYcHXenlVzpjEU= +github.com/sagernet/sing-mux v0.1.7-rc.1 h1:4XlQSeIqKA6uaW1KGQA9wCG5zt9ajhvc/zJnnZV/n1c= +github.com/sagernet/sing-mux v0.1.7-rc.1/go.mod h1:KK5zCbNujj5kn36G+wLFROOXyJhaaXLyaZWY2w7kBNQ= +github.com/sagernet/sing-quic v0.1.7-rc.1 h1:HoZC7YFmHCpfrUNXbousGauLw6yg25f6qcIkqrYIDbg= +github.com/sagernet/sing-quic v0.1.7-rc.1/go.mod h1:+XDZtFPD8YJ4V1MMObOdf2k5+Ma1jy75OlRnlBUHihI= +github.com/sagernet/sing-shadowsocks2 v0.1.6-rc.1 h1:E+8OyyVg0YfFNUmxMx9jYBEhjLYMQSAMzJrUmE934bo= +github.com/sagernet/sing-shadowsocks2 v0.1.6-rc.1/go.mod h1:wFkU7sKxyZADS/idtJqBhtc+QBf5iwX9nZO7ymcn6MM= +github.com/sagernet/sing-shadowtls v0.1.4 h1:aTgBSJEgnumzFenPvc+kbD9/W0PywzWevnVpEx6Tw3k= +github.com/sagernet/sing-shadowtls v0.1.4/go.mod h1:F8NBgsY5YN2beQavdgdm1DPlhaKQlaL6lpDdcBglGK4= +github.com/sagernet/sing-tun v0.2.0-rc.1 h1:CnlxRgrJKAMKYNuJOcKie6TjRz8wremEq1wndLup7cA= +github.com/sagernet/sing-tun v0.2.0-rc.1/go.mod h1:hpbL9jNAbYT9G2EHCpCXVIgSrM/2Wgnrm/Hped+8zdY= +github.com/sagernet/sing-vmess v0.1.8 h1:XVWad1RpTy9b5tPxdm5MCU8cGfrTGdR8qCq6HV2aCNc= +github.com/sagernet/sing-vmess v0.1.8/go.mod h1:vhx32UNzTDUkNwOyIjcZQohre1CaytquC5mPplId8uA= +github.com/sagernet/smux v0.0.0-20231208180855-7041f6ea79e7 h1:DImB4lELfQhplLTxeq2z31Fpv8CQqqrUwTbrIRumZqQ= +github.com/sagernet/smux v0.0.0-20231208180855-7041f6ea79e7/go.mod h1:FP9X2xjT/Az1EsG/orYYoC+5MojWnuI7hrffz8fGwwo= +github.com/sagernet/tfo-go v0.0.0-20231209031829-7b5343ac1dc6 h1:z3SJQhVyU63FT26Wn/UByW6b7q8QKB0ZkPqsyqcz2PI= +github.com/sagernet/tfo-go v0.0.0-20231209031829-7b5343ac1dc6/go.mod h1:73xRZuxwkFk4aiLw28hG8W6o9cr2UPrGL9pdY2UTbvY= +github.com/sagernet/utls v1.5.4 h1:KmsEGbB2dKUtCNC+44NwAdNAqnqQ6GA4pTO0Yik56co= +github.com/sagernet/utls v1.5.4/go.mod h1:CTGxPWExIloRipK3XFpYv0OVyhO8kk3XCGW/ieyTh1s= +github.com/sagernet/wireguard-go v0.0.0-20231215174105-89dec3b2f3e8 h1:R0OMYAScomNAVpTfbHFpxqJpvwuhxSRi+g6z7gZhABs= +github.com/sagernet/wireguard-go v0.0.0-20231215174105-89dec3b2f3e8/go.mod h1:K4J7/npM+VAMUeUmTa2JaA02JmyheP0GpRBOUvn3ecc= +github.com/sagernet/ws v0.0.0-20231204124109-acfe8907c854 h1:6uUiZcDRnZSAegryaUGwPC/Fj13JSHwiTftrXhMmYOc= +github.com/sagernet/ws v0.0.0-20231204124109-acfe8907c854/go.mod h1:LtfoSK3+NG57tvnVEHgcuBW9ujgE8enPSgzgwStwCAA= +github.com/samber/lo v1.38.1 h1:j2XEAqXKb09Am4ebOg31SpvzUTTs6EN3VfgeLUhPdXM= +github.com/samber/lo v1.38.1/go.mod h1:+m/ZKRl6ClXCE2Lgf3MsQlWfh4bn1bz6CXEOxnEXnEA= +github.com/scjalliance/comshim v0.0.0-20230315213746-5e51f40bd3b9 h1:rc/CcqLH3lh8n+csdOuDfP+NuykE0U6AeYSJJHKDgSg= +github.com/scjalliance/comshim v0.0.0-20230315213746-5e51f40bd3b9/go.mod h1:a/83NAfUXvEuLpmxDssAXxgUgrEy12MId3Wd7OTs76s= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0= +github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/u-root/uio v0.0.0-20230220225925-ffce2a382923 h1:tHNk7XK9GkmKUR6Gh8gVBKXc2MVSZ4G/NnWLtzw4gNA= +github.com/u-root/uio v0.0.0-20230220225925-ffce2a382923/go.mod h1:eLL9Nub3yfAho7qB0MzZizFhTU2QkLeoVsWdHtDW264= +github.com/vishvananda/netlink v1.2.1-beta.2.0.20230420174744-55c8b9515a01 h1:F9xjJm4IH8VjcqG4ujciOF+GIM4mjPkHhWLLzOghPtM= +github.com/vishvananda/netlink v1.2.1-beta.2.0.20230420174744-55c8b9515a01/go.mod h1:cAAsePK2e15YDAMJNyOpGYEWNe4sIghTY7gpz4cX/Ik= +github.com/vishvananda/netns v0.0.0-20200728191858-db3c7e526aae/go.mod h1:DD4vA1DwXk04H54A1oHXtwZmA0grkVMdPxx/VGLCah0= +github.com/vishvananda/netns v0.0.0-20211101163701-50045581ed74 h1:gga7acRE695APm9hlsSMoOoE65U4/TcqNj90mc69Rlg= +github.com/vishvananda/netns v0.0.0-20211101163701-50045581ed74/go.mod h1:DD4vA1DwXk04H54A1oHXtwZmA0grkVMdPxx/VGLCah0= +github.com/zeebo/assert v1.1.0 h1:hU1L1vLTHsnO8x8c9KAR5GmM5QscxHg5RNU5z5qbUWY= +github.com/zeebo/assert v1.1.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= +github.com/zeebo/blake3 v0.2.3 h1:TFoLXsjeXqRNFxSbk35Dk4YtszE/MQQGK10BH4ptoTg= +github.com/zeebo/blake3 v0.2.3/go.mod h1:mjJjZpnsyIVtVgTOSpJ9vmRE4wgDeyt2HU3qXvvKCaQ= +github.com/zeebo/pcg v1.0.1 h1:lyqfGeWiv4ahac6ttHs+I5hwtH/+1mrhlCtVNQM2kHo= +github.com/zeebo/pcg v1.0.1/go.mod h1:09F0S9iiKrwn9rlI5yjLkmrug154/YRW6KnnXVDM/l4= +go.etcd.io/bbolt v1.3.7 h1:j+zJOnnEjF/kyHlDDgGnVL/AIqIJPq8UoB2GSNfkUfQ= +go.etcd.io/bbolt v1.3.7/go.mod h1:N9Mkw9X8x5fupy0IKsmuqVtoGDyxsaDlbk4Rd05IAQw= +go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= +go.uber.org/goleak v1.2.0 h1:xqgm/S+aQvhWFTtR0XK3Jvg7z8kGV8P4X14IzwN3Eqk= +go.uber.org/goleak v1.2.0/go.mod h1:XJYK+MuIchqpmGmUSAzotztawfKvYLUIgg7guXrwVUo= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo= +go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so= +go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M= +go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y= +golang.org/x/crypto v0.0.0-20190404164418-38d8ce5564a5/go.mod h1:WFFai1msRO1wXaEeE5yQxYXgSfI8pQAWXbQop6sCtWE= +golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8= +golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k= +golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= +golang.org/x/exp v0.0.0-20231219180239-dc181d75b848 h1:+iq7lrkxmFNBM7xx+Rae2W6uyPfhPeDWD+n+JgppptE= +golang.org/x/exp v0.0.0-20231219180239-dc181d75b848/go.mod h1:iRJReGqOEeBhDZGkGbynYwcHlctCvnjTYIamk7uXpHI= +golang.org/x/mod v0.14.0 h1:dGoOF9QVLYng8IHTm7BAyWqCqSheQ5pYWGhzW00YJr0= +golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c= +golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U= +golang.org/x/sync v0.5.0 h1:60k92dhOjHxJkrqnwsfl8KuaHbn/5dl0lUPUklKo3qE= +golang.org/x/sync v0.5.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20190403152447-81d4e9dc473e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200217220822-9197077df867/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20220622161953-175b2fd9d664/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220804214406-8e32c043e418/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.14.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= +golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.15.0 h1:y/Oo/a/q3IXu26lQgl04j/gjuBDOBlx7X6Om1j2CPW4= +golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= +golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.16.0 h1:GO788SKMRunPIBCXiQyo2AaexLstOrVhuAL5YwsckQM= +golang.org/x/tools v0.16.0/go.mod h1:kYVVN6I1mBNoB1OX+noeBjbRk4IUEPa7JJ+TJMEooJ0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/genproto/googleapis/rpc v0.0.0-20230822172742-b8732ec3820d h1:uvYuEyMHKNt+lT4K3bN6fGswmK8qSvcreM3BwjDh+y4= +google.golang.org/genproto/googleapis/rpc v0.0.0-20230822172742-b8732ec3820d/go.mod h1:+Bk1OCOj40wS2hwAMA+aCW9ypzm63QTBBHp6lQ3p+9M= +google.golang.org/grpc v1.59.0 h1:Z5Iec2pjwb+LEOqzpB2MR12/eKFhDPhuqW91O+4bwUk= +google.golang.org/grpc v1.59.0/go.mod h1:aUPDwccQo6OTjy7Hct4AfBPD1GptF4fyUjIkQ9YtF98= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8= +google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +lukechampine.com/blake3 v1.2.1 h1:YuqqRuaqsGV71BV/nm9xlI0MKUv4QC54jQnBChWbGnI= +lukechampine.com/blake3 v1.2.1/go.mod h1:0OFRp7fBtAylGVCO40o87sbupkyIGgbpv1+M1k1LM6k= diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 0000000..501dcaf --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,113 @@ +site_name: serenity +site_author: nekohasekai +repo_url: https://github.com/SagerNet/serenity +repo_name: SagerNet/serenity +copyright: Copyright © 2022 nekohasekai +site_description: The configuration generator for sing-box. +remote_branch: docs +edit_uri: "" +theme: + name: material + logo: assets/icon.svg + favicon: assets/icon.svg + palette: + - scheme: default + primary: white + toggle: + icon: material/brightness-7 + name: Switch to dark mode + - scheme: slate + primary: black + toggle: + icon: material/brightness-4 + name: Switch to light mode + features: + # - navigation.instant + - navigation.tracking + - navigation.tabs + - navigation.indexes + - navigation.expand + - navigation.sections + - header.autohide + - content.code.copy + - content.code.select + - content.code.annotate + icon: + admonition: + question: material/new-box +nav: + - Home: + - index.md + - Change Log: changelog.md + - Support: support.md + - Installation: + - Package Manager: installation/package-manager.md + - Docker: installation/docker.md + - Build from source: installation/build-from-source.md + - Configuration: + - configuration/index.md + - Subscription: configuration/subscription.md + - Template: configuration/template.md + - Profile: configuration/profile.md + - User: configuration/user.md +markdown_extensions: + - pymdownx.inlinehilite + - pymdownx.snippets + - pymdownx.superfences + - pymdownx.details + - pymdownx.critic + - pymdownx.caret + - pymdownx.keys + - pymdownx.mark + - pymdownx.tilde + - pymdownx.magiclink + - admonition + - attr_list + - md_in_html + - footnotes + - def_list + - pymdownx.highlight: + anchor_linenums: true + - pymdownx.tabbed: + alternate_style: true + - pymdownx.tasklist: + custom_checkbox: true + - pymdownx.emoji: + emoji_index: !!python/name:material.extensions.emoji.twemoji + emoji_generator: !!python/name:material.extensions.emoji.to_svg + - pymdownx.superfences: + custom_fences: + - name: mermaid + class: mermaid + format: !!python/name:pymdownx.superfences.fence_code_format +extra: + social: + - icon: fontawesome/brands/github + link: https://github.com/SagerNet/serenity + generator: false +plugins: + - search + - i18n: + docs_structure: suffix + fallback_to_default: true + languages: + - build: true + default: true + locale: en + name: English + - build: true + default: false + locale: zh + name: 简体中文 + nav_translations: + Home: 开始 + Change Log: 更新日志 + Support: 支持 + + Installation: 安装 + Package Manager: 包管理器 + Build from source: 从源代码构建 + + Configuration: 配置 + reconfigure_material: true + reconfigure_search: true \ No newline at end of file diff --git a/option/message.go b/option/message.go new file mode 100644 index 0000000..826ddf5 --- /dev/null +++ b/option/message.go @@ -0,0 +1,17 @@ +package option + +import "github.com/sagernet/sing/common/json" + +type TypedMessage[T any] struct { + Message json.RawMessage + Value T +} + +func (m *TypedMessage[T]) MarshalJSON() ([]byte, error) { + return json.Marshal(m.Value) +} + +func (m *TypedMessage[T]) UnmarshalJSON(bytes []byte) error { + m.Message = bytes + return json.Unmarshal(bytes, &m.Value) +} diff --git a/option/options.go b/option/options.go new file mode 100644 index 0000000..3f2eb00 --- /dev/null +++ b/option/options.go @@ -0,0 +1,80 @@ +package option + +import ( + "bytes" + "time" + + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common/json" + "github.com/sagernet/sing/common/json/badjson" +) + +type _Options struct { + RawMessage json.RawMessage `json:"-"` + Log *option.LogOptions `json:"log,omitempty"` + Listen string `json:"listen,omitempty"` + TLS *option.InboundTLSOptions `json:"tls,omitempty"` + CacheFile string `json:"cache_file,omitempty"` + + Outbounds []option.Listable[option.Outbound] `json:"outbounds,omitempty"` + Subscriptions []Subscription `json:"subscriptions,omitempty"` + Templates []Template `json:"templates,omitempty"` + Profiles []Profile `json:"profiles,omitempty"` + Users []User `json:"users,omitempty"` +} + +type Options _Options + +func (o *Options) UnmarshalJSON(content []byte) error { + decoder := json.NewDecoder(bytes.NewReader(content)) + decoder.DisallowUnknownFields() + err := decoder.Decode((*_Options)(o)) + if err != nil { + return err + } + o.RawMessage = content + return nil +} + +type User struct { + Name string `json:"name,omitempty"` + Password string `json:"password,omitempty"` + Profile option.Listable[string] `json:"profile,omitempty"` + DefaultProfile string `json:"default_profile,omitempty"` +} + +const ( + DefaultSubscriptionUpdateInterval = 1 * time.Hour +) + +type Subscription struct { + Name string `json:"name,omitempty"` + URL string `json:"url,omitempty"` + UserAgent string `json:"user_agent,omitempty"` + UpdateInterval option.Duration `json:"update_interval,omitempty"` + Process option.Listable[OutboundProcessOptions] `json:"process,omitempty"` + DeDuplication bool `json:"deduplication,omitempty"` + GenerateSelector bool `json:"generate_selector,omitempty"` + GenerateURLTest bool `json:"generate_urltest,omitempty"` + URLTestTagSuffix string `json:"urltest_suffix,omitempty"` + CustomSelector *option.SelectorOutboundOptions `json:"custom_selector,omitempty"` + CustomURLTest *option.URLTestOutboundOptions `json:"custom_urltest,omitempty"` +} + +type OutboundProcessOptions struct { + Filter option.Listable[string] `json:"filter,omitempty"` + Exclude option.Listable[string] `json:"exclude,omitempty"` + FilterOutboundType option.Listable[string] `json:"filter_outbound_type,omitempty"` + ExcludeOutboundType option.Listable[string] `json:"exclude_outbound_type,omitempty"` + Rename *badjson.TypedMap[string, string] `json:"rename,omitempty"` + RemoveEmoji bool `json:"remove_emoji,omitempty"` +} + +type Profile struct { + Name string `json:"name,omitempty"` + Template string `json:"template,omitempty"` + TemplateForPlatform *badjson.TypedMap[string, string] `json:"template_for_platform,omitempty"` + TemplateForUserAgent *badjson.TypedMap[string, string] `json:"template_for_user_agent,omitempty"` + Outbound option.Listable[string] `json:"outbound,omitempty"` + Subscription option.Listable[string] `json:"subscription,omitempty"` +} diff --git a/option/template.go b/option/template.go new file mode 100644 index 0000000..247cbeb --- /dev/null +++ b/option/template.go @@ -0,0 +1,77 @@ +package option + +import ( + "github.com/sagernet/serenity/common/semver" + "github.com/sagernet/sing-box/option" + dns "github.com/sagernet/sing-dns" + "github.com/sagernet/sing/common/json/badjson" +) + +type Template struct { + Name string `json:"name,omitempty"` + + // Global + + DomainStrategy option.DomainStrategy `json:"domain_strategy,omitempty"` + DisableTrafficBypass bool `json:"disable_traffic_bypass,omitempty"` + DisableRuleSet bool `json:"disable_rule_set,omitempty"` + RemoteResolve bool `json:"remote_resolve,omitempty"` + + // DNS + DNSDefault string `json:"dns_default,omitempty"` + DNSLocal string `json:"dns_local,omitempty"` + EnableFakeIP bool `json:"enable_fakeip,omitempty"` + PreDNSRules []option.DNSRule `json:"pre_dns_rules,omitempty"` + CustomDNSRules []option.DNSRule `json:"custom_dns_rules,omitempty"` + + // Inbound + DisableTUN bool `json:"disable_tun,omitempty"` + DisableSystemProxy bool `json:"disable_system_proxy,omitempty"` + CustomTUN *TypedMessage[option.TunInboundOptions] `json:"custom_tun,omitempty"` + CustomMixed *TypedMessage[option.HTTPMixedInboundOptions] `json:"custom_mixed,omitempty"` + + // Outbound + ExtraGroups []ExtraGroup `json:"extra_groups,omitempty"` + GenerateGlobalURLTest bool `json:"generate_global_urltest,omitempty"` + DirectTag string `json:"direct_tag,omitempty"` + DefaultTag string `json:"default_tag,omitempty"` + URLTestTag string `json:"urltest_tag,omitempty"` + CustomDirect *option.DirectOutboundOptions `json:"custom_direct,omitempty"` + CustomSelector *option.SelectorOutboundOptions `json:"custom_selector,omitempty"` + CustomURLTest *option.URLTestOutboundOptions `json:"custom_urltest,omitempty"` + + // Route + DisableDefaultRules bool `json:"disable_default_rules,omitempty"` + PreRules []option.Rule `json:"pre_rules,omitempty"` + CustomRules []option.Rule `json:"custom_rules,omitempty"` + CustomRulesForVersionLessThan badjson.TypedMap[semver.Version, []option.Rule] `json:"custom_rules_for_version_less_than,omitempty"` + EnableJSDelivr bool `json:"enable_jsdelivr,omitempty"` + CustomGeoIP *option.GeoIPOptions `json:"custom_geoip,omitempty"` + CustomGeosite *option.GeositeOptions `json:"custom_geosite,omitempty"` + CustomRuleSet []option.RuleSet `json:"custom_rule_set,omitempty"` + + // Experimental + DisableCacheFile bool `json:"disable_cache_file,omitempty"` + DisableClashMode bool `json:"disable_clash_mode,omitempty"` + ClashModeRule string `json:"clash_mode_rule,omitempty"` + ClashModeGlobal string `json:"clash_mode_global,omitempty"` + ClashModeDirect string `json:"clash_mode_direct,omitempty"` + CustomClashAPI *TypedMessage[option.ClashAPIOptions] `json:"custom_clash_api,omitempty"` + + // Debug + PProfListen string `json:"pprof_listen,omitempty"` + MemoryLimit option.MemoryBytes `json:"memory_limit,omitempty"` +} + +func (t Template) DisableIPv6() bool { + return t.DomainStrategy == option.DomainStrategy(dns.DomainStrategyUseIPv4) +} + +type ExtraGroup struct { + Tag string `json:"tag,omitempty"` + Type string `json:"type,omitempty"` + Filter option.Listable[string] `json:"filter,omitempty"` + Exclude option.Listable[string] `json:"exclude,omitempty"` + CustomSelector *option.SelectorOutboundOptions `json:"custom_selector,omitempty"` + CustomURLTest *option.URLTestOutboundOptions `json:"custom_urltest,omitempty"` +} diff --git a/release/config/config.json b/release/config/config.json new file mode 100644 index 0000000..423d143 --- /dev/null +++ b/release/config/config.json @@ -0,0 +1,8 @@ +{ + "listen": ":8080", + "users": [], + "subscriptions": [], + "outbounds": [], + "templates": [], + "profiles": [] +} diff --git a/release/config/serenity.service b/release/config/serenity.service new file mode 100644 index 0000000..3f47908 --- /dev/null +++ b/release/config/serenity.service @@ -0,0 +1,16 @@ +[Unit] +Description=serenity service +Documentation=https://serenity.sagernet.org +After=network.target nss-lookup.target + +[Service] +CapabilityBoundingSet=CAP_NET_ADMIN CAP_NET_BIND_SERVICE CAP_SYS_PTRACE CAP_DAC_READ_SEARCH +AmbientCapabilities=CAP_NET_ADMIN CAP_NET_BIND_SERVICE CAP_SYS_PTRACE CAP_DAC_READ_SEARCH +ExecStart=/usr/bin/serenity -D /var/lib/serenity -C /etc/serenity run +ExecReload=/bin/kill -HUP $MAINPID +Restart=on-failure +RestartSec=10s +LimitNOFILE=infinity + +[Install] +WantedBy=multi-user.target diff --git a/release/config/serenity@.service b/release/config/serenity@.service new file mode 100644 index 0000000..9902250 --- /dev/null +++ b/release/config/serenity@.service @@ -0,0 +1,16 @@ +[Unit] +Description=serenity service +Documentation=https://serenity.sagernet.org +After=network.target nss-lookup.target + +[Service] +CapabilityBoundingSet=CAP_NET_ADMIN CAP_NET_BIND_SERVICE CAP_SYS_PTRACE CAP_DAC_READ_SEARCH +AmbientCapabilities=CAP_NET_ADMIN CAP_NET_BIND_SERVICE CAP_SYS_PTRACE CAP_DAC_READ_SEARCH +ExecStart=/usr/bin/serenity -D /var/lib/serenity-%i -c /etc/serenity/%i.json run +ExecReload=/bin/kill -HUP $MAINPID +Restart=on-failure +RestartSec=10s +LimitNOFILE=infinity + +[Install] +WantedBy=multi-user.target diff --git a/release/local/enable.sh b/release/local/enable.sh new file mode 100755 index 0000000..db64920 --- /dev/null +++ b/release/local/enable.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash + +set -e -o pipefail + +sudo systemctl enable serenity +sudo systemctl start serenity +sudo journalctl -u serenity --output cat -f diff --git a/release/local/install.sh b/release/local/install.sh new file mode 100755 index 0000000..b2d079e --- /dev/null +++ b/release/local/install.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash + +set -e -o pipefail + +if [ -d /usr/local/go ]; then + export PATH="$PATH:/usr/local/go/bin" +fi + +DIR=$(dirname "$0") +PROJECT=$DIR/../.. + +pushd $PROJECT +go install -v -trimpath -ldflags "-s -w -buildid=" ./cmd/serenity +popd + +sudo cp $(go env GOPATH)/bin/serenity /usr/local/bin/ +sudo mkdir -p /usr/local/etc/serenity +sudo cp $PROJECT/release/config/config.json /usr/local/etc/serenity/config.json +sudo cp $DIR/serenity.service /etc/systemd/system +sudo systemctl daemon-reload diff --git a/release/local/install_go.sh b/release/local/install_go.sh new file mode 100755 index 0000000..ea64fec --- /dev/null +++ b/release/local/install_go.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash + +set -e -o pipefail + +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') +curl -Lo go.tar.gz "https://go.dev/dl/go$go_version.linux-amd64.tar.gz" +sudo rm -rf /usr/local/go +sudo tar -C /usr/local -xzf go.tar.gz +rm go.tar.gz diff --git a/release/local/reinstall.sh b/release/local/reinstall.sh new file mode 100755 index 0000000..e8dc0ef --- /dev/null +++ b/release/local/reinstall.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash + +set -e -o pipefail + +if [ -d /usr/local/go ]; then + export PATH="$PATH:/usr/local/go/bin" +fi + +DIR=$(dirname "$0") +PROJECT=$DIR/../.. + +pushd $PROJECT +go install -v -trimpath -ldflags "-s -w -buildid=" ./cmd/serenity +popd + +sudo systemctl stop serenity +sudo cp $(go env GOPATH)/bin/serenity /usr/local/bin/ +sudo systemctl start serenity diff --git a/release/local/serenity.service b/release/local/serenity.service new file mode 100644 index 0000000..b641eef --- /dev/null +++ b/release/local/serenity.service @@ -0,0 +1,16 @@ +[Unit] +Description=serenity service +Documentation=https://serenity.sagernet.org +After=network.target nss-lookup.target + +[Service] +CapabilityBoundingSet=CAP_NET_ADMIN CAP_NET_BIND_SERVICE CAP_SYS_PTRACE CAP_DAC_READ_SEARCH +AmbientCapabilities=CAP_NET_ADMIN CAP_NET_BIND_SERVICE CAP_SYS_PTRACE CAP_DAC_READ_SEARCH +ExecStart=/usr/local/bin/serenity -D /var/lib/serenity -C /usr/local/etc/serenity run +ExecReload=/bin/kill -HUP $MAINPID +Restart=on-failure +RestartSec=10s +LimitNOFILE=infinity + +[Install] +WantedBy=multi-user.target diff --git a/release/local/uninstall.sh b/release/local/uninstall.sh new file mode 100755 index 0000000..2723657 --- /dev/null +++ b/release/local/uninstall.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash + +sudo systemctl stop serenity +sudo rm -rf /var/lib/serenity +sudo rm -rf /usr/local/bin/serenity +sudo rm -rf /usr/local/etc/serenity +sudo rm -rf /etc/systemd/system/serenity.service +sudo systemctl daemon-reload diff --git a/release/local/update.sh b/release/local/update.sh new file mode 100755 index 0000000..86ea315 --- /dev/null +++ b/release/local/update.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash + +set -e -o pipefail + +DIR=$(dirname "$0") +PROJECT=$DIR/../.. + +pushd $PROJECT +git fetch +git reset FETCH_HEAD --hard +git clean -fdx +popd + +$DIR/reinstall.sh \ No newline at end of file diff --git a/server/profile.go b/server/profile.go new file mode 100644 index 0000000..cd82d55 --- /dev/null +++ b/server/profile.go @@ -0,0 +1,144 @@ +package server + +import ( + "context" + "regexp" + + "github.com/sagernet/serenity/common/metadata" + "github.com/sagernet/serenity/option" + "github.com/sagernet/serenity/subscription" + "github.com/sagernet/serenity/template" + boxOption "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/logger" +) + +type ProfileManager struct { + ctx context.Context + logger logger.Logger + subscription *subscription.Manager + outbounds [][]boxOption.Outbound + profiles []*Profile + defaultProfile *Profile +} + +type Profile struct { + option.Profile + manager *ProfileManager + template *template.Template + templateForPlatform map[metadata.Platform]*template.Template + templateForUserAgent map[*regexp.Regexp]*template.Template + groups []ExtraGroup +} + +type ExtraGroup struct { + option.ExtraGroup + filterRegex []*regexp.Regexp +} + +func NewProfileManager( + ctx context.Context, + logger logger.Logger, + subscriptionManager *subscription.Manager, + templateManager *template.Manager, + outbounds [][]boxOption.Outbound, + rawProfiles []option.Profile, +) (*ProfileManager, error) { + manager := &ProfileManager{ + ctx: ctx, + logger: logger, + subscription: subscriptionManager, + outbounds: outbounds, + } + for profileIndex, profile := range rawProfiles { + if profile.Name == "" { + return nil, E.New("initialize profile[", profileIndex, "]: missing name") + } + var ( + defaultTemplate *template.Template + templateForPlatform = make(map[metadata.Platform]*template.Template) + templateForUserAgent = make(map[*regexp.Regexp]*template.Template) + ) + if profile.Template != "" { + defaultTemplate = templateManager.TemplateByName(profile.Template) + if defaultTemplate == nil { + return nil, E.New("initialize profile[", profile.Name, "]: template not found: ", profile.Template) + } + } else { + defaultTemplate = template.Default + } + if profile.TemplateForPlatform != nil { + for templateIndex, entry := range profile.TemplateForPlatform.Entries() { + platform, err := metadata.ParsePlatform(entry.Key) + if err != nil { + return nil, E.Cause(err, "initialize profile[", profile.Name, "]: parse template_for_platform[", templateIndex, "]") + } + customTemplate := templateManager.TemplateByName(entry.Value) + if customTemplate == nil { + return nil, E.New("initialize profile[", profile.Name, "]: parse template_for_platform[", entry.Key, "]: template not found: ", entry.Value) + } + templateForPlatform[platform] = customTemplate + } + } + if profile.TemplateForUserAgent != nil { + for templateIndex, entry := range profile.TemplateForUserAgent.Entries() { + regex, err := regexp.Compile(entry.Key) + if err != nil { + return nil, E.Cause(err, "initialize profile[", profile.Name, "]: parse template_for_user_agent[", templateIndex, "]") + } + customTemplate := templateManager.TemplateByName(entry.Value) + if customTemplate == nil { + return nil, E.New("initialize profile[", profile.Name, "]: parse template_for_user_agent[", entry.Key, "]: template not found: ", entry.Value) + } + templateForUserAgent[regex] = customTemplate + } + } + manager.profiles = append(manager.profiles, &Profile{ + Profile: profile, + manager: manager, + template: defaultTemplate, + templateForPlatform: templateForPlatform, + templateForUserAgent: templateForUserAgent, + }) + } + if len(manager.profiles) > 0 { + manager.defaultProfile = manager.profiles[0] + } + return manager, nil +} + +func (m *ProfileManager) ProfileByName(name string) *Profile { + for _, it := range m.profiles { + if it.Name == name { + return it + } + } + return nil +} + +func (m *ProfileManager) DefaultProfile() *Profile { + return m.defaultProfile +} + +func (p *Profile) Render(metadata metadata.Metadata) (*boxOption.Options, error) { + selectedTemplate, loaded := p.templateForPlatform[metadata.Platform] + if !loaded { + for regex, it := range p.templateForUserAgent { + if regex.MatchString(metadata.UserAgent) { + selectedTemplate = it + break + } + } + } + if selectedTemplate == nil { + selectedTemplate = p.template + } + outbounds := common.Filter(p.manager.outbounds, func(it []boxOption.Outbound) bool { + return common.Contains(p.Outbound, it[0].Tag) + }) + subscriptions := common.Filter(p.manager.subscription.Subscriptions(), func(it *subscription.Subscription) bool { + return common.Contains(p.Subscription, it.Name) + }) + return selectedTemplate.Render(metadata, p.Name, outbounds, subscriptions) +} diff --git a/server/server.go b/server/server.go new file mode 100644 index 0000000..0824282 --- /dev/null +++ b/server/server.go @@ -0,0 +1,192 @@ +package server + +import ( + "context" + "errors" + "net" + "net/http" + "os" + "time" + + "github.com/sagernet/serenity/common/cachefile" + "github.com/sagernet/serenity/option" + "github.com/sagernet/serenity/subscription" + "github.com/sagernet/serenity/template" + "github.com/sagernet/sing-box/common/tls" + "github.com/sagernet/sing-box/log" + boxOption "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common" + E "github.com/sagernet/sing/common/exceptions" + F "github.com/sagernet/sing/common/format" + "github.com/sagernet/sing/service" + + "github.com/go-chi/chi/v5" + "golang.org/x/net/http2" +) + +type Server struct { + createdAt time.Time + ctx context.Context + logFactory log.Factory + logger log.Logger + chiRouter chi.Router + httpServer *http.Server + tlsConfig tls.ServerConfig + cacheFile *cachefile.CacheFile + subscription *subscription.Manager + template *template.Manager + profile *ProfileManager + users []option.User + userMap map[string][]option.User +} + +func New(ctx context.Context, options option.Options) (*Server, error) { + ctx = service.ContextWithDefaultRegistry(ctx) + createdAt := time.Now() + logFactory, err := log.New(log.Options{ + Options: common.PtrValueOrDefault(options.Log), + DefaultWriter: os.Stderr, + BaseTime: createdAt, + }) + if err != nil { + return nil, E.Cause(err, "create log factory") + } + chiRouter := chi.NewRouter() + httpServer := &http.Server{ + Addr: options.Listen, + Handler: chiRouter, + } + if httpServer.Addr == "" { + if options.TLS != nil && options.TLS.Enabled { + httpServer.Addr = ":443" + } else { + httpServer.Addr = ":80" + } + } + var tlsConfig tls.ServerConfig + if options.TLS != nil { + tlsConfig, err = tls.NewServer(ctx, logFactory.NewLogger("tls"), common.PtrValueOrDefault(options.TLS)) + if err != nil { + return nil, err + } + } + var cacheFilePath string + if options.CacheFile != "" { + cacheFilePath = options.CacheFile + } else { + cacheFilePath = "cache.db" + } + cacheFile := cachefile.New(cacheFilePath) + subscriptionManager, err := subscription.NewSubscriptionManager( + ctx, + logFactory.NewLogger("subscription"), + cacheFile, + options.Subscriptions) + if err != nil { + return nil, err + } + templateManager, err := template.NewManager( + ctx, + logFactory.NewLogger("template"), + options.Templates) + if err != nil { + return nil, err + } + profileManager, err := NewProfileManager( + ctx, + logFactory.NewLogger("profile"), + subscriptionManager, + templateManager, + common.Map(options.Outbounds, func(it boxOption.Listable[boxOption.Outbound]) []boxOption.Outbound { + return it + }), + options.Profiles, + ) + if err != nil { + return nil, err + } + userMap := make(map[string][]option.User) + for _, user := range options.Users { + userMap[user.Name] = append(userMap[user.Name], user) + } + return &Server{ + createdAt: createdAt, + ctx: ctx, + logFactory: logFactory, + logger: logFactory.Logger(), + chiRouter: chiRouter, + httpServer: httpServer, + tlsConfig: tlsConfig, + cacheFile: cacheFile, + subscription: subscriptionManager, + template: templateManager, + profile: profileManager, + users: options.Users, + userMap: userMap, + }, nil +} + +func (s *Server) Start() error { + s.initializeRoutes() + err := s.cacheFile.Start() + if err != nil { + return err + } + err = s.subscription.Start() + if err != nil { + return err + } + listener, err := net.Listen("tcp", s.httpServer.Addr) + if err != nil { + return err + } + if s.tlsConfig != nil { + err = s.tlsConfig.Start() + if err != nil { + return err + } + err = http2.ConfigureServer(s.httpServer, new(http2.Server)) + if err != nil { + return err + } + stdConfig, err := s.tlsConfig.Config() + if err != nil { + return err + } + s.httpServer.TLSConfig = stdConfig + } + s.logger.Info("server started at ", listener.Addr()) + go func() { + if s.httpServer.TLSConfig != nil { + err = s.httpServer.ServeTLS(listener, "", "") + } else { + err = s.httpServer.Serve(listener) + } + if err != nil && !errors.Is(err, http.ErrServerClosed) { + s.logger.Error("server serve error: ", err) + } + }() + err = s.postStart() + if err != nil { + return err + } + s.logger.Info("serenity started (", F.Seconds(time.Since(s.createdAt).Seconds()), "s)") + return nil +} + +func (s *Server) postStart() error { + err := s.subscription.PostStart() + if err != nil { + return E.Cause(err, "post-start subscription manager") + } + return nil +} + +func (s *Server) Close() error { + return common.Close( + s.logFactory, + common.PtrOrNil(s.httpServer), + s.tlsConfig, + common.PtrOrNil(s.cacheFile), + ) +} diff --git a/server/server_render.go b/server/server_render.go new file mode 100644 index 0000000..038ce5d --- /dev/null +++ b/server/server_render.go @@ -0,0 +1,124 @@ +package server + +import ( + "bytes" + "encoding/json" + "net/http" + + M "github.com/sagernet/serenity/common/metadata" + "github.com/sagernet/serenity/option" + "github.com/sagernet/sing/common" + E "github.com/sagernet/sing/common/exceptions" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/cors" + "github.com/go-chi/render" +) + +func (s *Server) initializeRoutes() { + s.chiRouter.Use(cors.New(cors.Options{ + AllowedOrigins: []string{"*"}, + AllowedMethods: []string{"GET"}, + AllowedHeaders: []string{"Content-Type", "Authorization"}, + }).Handler) + s.chiRouter.Get("/", s.render) + s.chiRouter.Get("/{profileName}", s.render) +} + +func (s *Server) render(writer http.ResponseWriter, request *http.Request) { + profileName := chi.URLParam(request, "profileName") + if profileName == "" { + // compatibility with legacy versions + profileName = request.URL.Query().Get("profile") + } + var profile *Profile + if len(s.users) == 0 { + if profileName == "" { + profile = s.profile.DefaultProfile() + } else { + profile = s.profile.ProfileByName(profileName) + } + } else { + user := s.authorization(request) + if user == nil { + writer.WriteHeader(http.StatusUnauthorized) + s.accessLog(request, http.StatusUnauthorized, 0) + return + } + if len(user.Profile) == 0 { + writer.WriteHeader(http.StatusNotFound) + s.accessLog(request, http.StatusNotFound, 0) + return + } + if profileName == "" { + profileName = user.DefaultProfile + } + if profileName == "" { + profileName = user.Profile[0] + } + if !common.Contains(user.Profile, profileName) { + writer.WriteHeader(http.StatusNotFound) + s.accessLog(request, http.StatusNotFound, 0) + return + } + profile = s.profile.ProfileByName(profileName) + } + if profile == nil { + writer.WriteHeader(http.StatusNotFound) + s.accessLog(request, http.StatusNotFound, 0) + return + } + metadata := M.Detect(request.Header.Get("User-Agent")) + options, err := profile.Render(metadata) + if err != nil { + s.logger.Error(E.Cause(err, "render options")) + render.Status(request, http.StatusInternalServerError) + render.PlainText(writer, request, err.Error()) + s.accessLog(request, http.StatusInternalServerError, len(err.Error())) + return + } + var buffer bytes.Buffer + encoder := json.NewEncoder(&buffer) + encoder.SetIndent("", " ") + err = encoder.Encode(&options) + if err != nil { + s.logger.Error(E.Cause(err, "marshal options")) + render.Status(request, http.StatusInternalServerError) + render.PlainText(writer, request, err.Error()) + s.accessLog(request, http.StatusInternalServerError, len(err.Error())) + return + } + writer.Header().Set("Content-Type", "application/json") + writer.WriteHeader(http.StatusOK) + writer.Write(buffer.Bytes()) + s.accessLog(request, http.StatusOK, buffer.Len()) +} + +func (s *Server) accessLog(request *http.Request, responseCode int, responseLen int) { + var userString string + if username, password, ok := request.BasicAuth(); ok { + if responseCode == http.StatusUnauthorized { + userString = username + ":" + password + } else { + userString = username + } + } + s.logger.Debug("accepted ", request.RemoteAddr, " - ", userString, " \"", request.Method, " ", request.URL, " ", request.Proto, "\" ", responseCode, " ", responseLen, " \"", request.UserAgent(), "\"") +} + +func (s *Server) authorization(request *http.Request) *option.User { + username, password, ok := request.BasicAuth() + if !ok { + return nil + } + users, loaded := s.userMap[username] + if !loaded { + return nil + } + for _, user := range users { + if user.Password == password { + return &user + } + } + return nil +} diff --git a/subscription/deduplication.go b/subscription/deduplication.go new file mode 100644 index 0000000..1d1aa67 --- /dev/null +++ b/subscription/deduplication.go @@ -0,0 +1,87 @@ +package subscription + +import ( + "context" + "net/netip" + "sync" + + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-dns" + "github.com/sagernet/sing/common" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + "github.com/sagernet/sing/common/task" +) + +func Deduplication(ctx context.Context, servers []option.Outbound) []option.Outbound { + resolveCtx := &resolveContext{ + ctx: ctx, + dnsClient: dns.NewClient(dns.ClientOptions{ + DisableExpire: true, + Logger: log.NewNOPFactory().Logger(), + }), + dnsTransport: common.Must1(dns.NewTLSTransport("google", ctx, N.SystemDialer, M.ParseSocksaddr("1.1.1.1"))), + } + uniqueServers := make([]netip.AddrPort, len(servers)) + var ( + resolveGroup task.Group + resultAccess sync.Mutex + ) + for index, server := range servers { + currentIndex := index + currentServer := server + resolveGroup.Append0(func(ctx context.Context) error { + destination := resolveDestination(resolveCtx, currentServer) + if destination.IsValid() { + resultAccess.Lock() + uniqueServers[currentIndex] = destination + resultAccess.Unlock() + } + return nil + }) + resolveGroup.Concurrency(5) + _ = resolveGroup.Run(ctx) + } + uniqueServerMap := make(map[netip.AddrPort]bool) + var newServers []option.Outbound + for index, server := range servers { + destination := uniqueServers[index] + if destination.IsValid() { + if uniqueServerMap[destination] { + continue + } + uniqueServerMap[destination] = true + } + newServers = append(newServers, server) + } + return newServers +} + +type resolveContext struct { + ctx context.Context + dnsClient *dns.Client + dnsTransport dns.Transport +} + +func resolveDestination(ctx *resolveContext, server option.Outbound) netip.AddrPort { + rawOptions, err := server.RawOptions() + if err != nil { + return netip.AddrPort{} + } + serverOptionsWrapper, loaded := rawOptions.(option.ServerOptionsWrapper) + if !loaded { + return netip.AddrPort{} + } + serverOptions := serverOptionsWrapper.TakeServerOptions().Build() + if serverOptions.IsIP() { + return serverOptions.AddrPort() + } + if serverOptions.IsFqdn() { + addresses, lookupErr := ctx.dnsClient.Lookup(ctx.ctx, ctx.dnsTransport, serverOptions.Fqdn, dns.DomainStrategyPreferIPv4) + if lookupErr == nil && len(addresses) > 0 { + return netip.AddrPortFrom(addresses[0], serverOptions.Port) + } + } + return netip.AddrPort{} +} diff --git a/subscription/parser/clash.go b/subscription/parser/clash.go new file mode 100644 index 0000000..250cd7b --- /dev/null +++ b/subscription/parser/clash.go @@ -0,0 +1,283 @@ +package parser + +import ( + "strings" + + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/option" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/format" + N "github.com/sagernet/sing/common/network" + + "github.com/Dreamacro/clash/adapter" + clash_outbound "github.com/Dreamacro/clash/adapter/outbound" + "github.com/Dreamacro/clash/common/structure" + "github.com/Dreamacro/clash/config" + "github.com/Dreamacro/clash/constant" +) + +func ParseClashSubscription(content string) ([]option.Outbound, error) { + config, err := config.UnmarshalRawConfig([]byte(content)) + if err != nil { + return nil, E.Cause(err, "parse clash config") + } + decoder := structure.NewDecoder(structure.Option{TagName: "proxy", WeaklyTypedInput: true}) + var outbounds []option.Outbound + for i, proxyMapping := range config.Proxy { + proxy, err := adapter.ParseProxy(proxyMapping) + if err != nil { + return nil, E.Cause(err, "parse proxy ", i) + } + var outbound option.Outbound + outbound.Tag = proxy.Name() + switch proxy.Type() { + case constant.Shadowsocks: + ssOption := &clash_outbound.ShadowSocksOption{} + err = decoder.Decode(proxyMapping, ssOption) + if err != nil { + return nil, err + } + outbound.Type = C.TypeShadowsocks + outbound.ShadowsocksOptions = option.ShadowsocksOutboundOptions{ + ServerOptions: option.ServerOptions{ + Server: ssOption.Server, + ServerPort: uint16(ssOption.Port), + }, + Password: ssOption.Password, + Method: clashShadowsocksCipher(ssOption.Cipher), + Plugin: clashPluginName(ssOption.Plugin), + PluginOptions: clashPluginOptions(ssOption.Plugin, ssOption.PluginOpts), + Network: clashNetworks(ssOption.UDP), + } + case constant.ShadowsocksR: + ssrOption := &clash_outbound.ShadowSocksROption{} + err = decoder.Decode(proxyMapping, ssrOption) + if err != nil { + return nil, err + } + outbound.Type = C.TypeShadowsocksR + outbound.ShadowsocksROptions = option.ShadowsocksROutboundOptions{ + ServerOptions: option.ServerOptions{ + Server: ssrOption.Server, + ServerPort: uint16(ssrOption.Port), + }, + Password: ssrOption.Password, + Method: clashShadowsocksCipher(ssrOption.Cipher), + Protocol: ssrOption.Protocol, + ProtocolParam: ssrOption.ProtocolParam, + Obfs: ssrOption.Obfs, + ObfsParam: ssrOption.ObfsParam, + Network: clashNetworks(ssrOption.UDP), + } + case constant.Trojan: + trojanOption := &clash_outbound.TrojanOption{} + err = decoder.Decode(proxyMapping, trojanOption) + if err != nil { + return nil, err + } + outbound.Type = C.TypeTrojan + outbound.TrojanOptions = option.TrojanOutboundOptions{ + ServerOptions: option.ServerOptions{ + Server: trojanOption.Server, + ServerPort: uint16(trojanOption.Port), + }, + Password: trojanOption.Password, + OutboundTLSOptionsContainer: option.OutboundTLSOptionsContainer{ + TLS: &option.OutboundTLSOptions{ + Enabled: true, + ALPN: trojanOption.ALPN, + ServerName: trojanOption.SNI, + Insecure: trojanOption.SkipCertVerify, + }, + }, + Transport: clashTransport(trojanOption.Network, clash_outbound.HTTPOptions{}, clash_outbound.HTTP2Options{}, trojanOption.GrpcOpts, trojanOption.WSOpts), + Network: clashNetworks(trojanOption.UDP), + } + case constant.Vmess: + vmessOption := &clash_outbound.VmessOption{} + err = decoder.Decode(proxyMapping, vmessOption) + if err != nil { + return nil, err + } + outbound.Type = C.TypeVMess + outbound.VMessOptions = option.VMessOutboundOptions{ + ServerOptions: option.ServerOptions{ + Server: vmessOption.Server, + ServerPort: uint16(vmessOption.Port), + }, + UUID: vmessOption.UUID, + Security: vmessOption.Cipher, + AlterId: vmessOption.AlterID, + OutboundTLSOptionsContainer: option.OutboundTLSOptionsContainer{ + TLS: &option.OutboundTLSOptions{ + Enabled: vmessOption.TLS, + ServerName: vmessOption.ServerName, + Insecure: vmessOption.SkipCertVerify, + }, + }, + Transport: clashTransport(vmessOption.Network, vmessOption.HTTPOpts, vmessOption.HTTP2Opts, vmessOption.GrpcOpts, vmessOption.WSOpts), + Network: clashNetworks(vmessOption.UDP), + } + case constant.Socks5: + socks5Option := &clash_outbound.Socks5Option{} + err = decoder.Decode(proxyMapping, socks5Option) + if err != nil { + return nil, err + } + + if socks5Option.TLS { + // TODO: print warning + continue + } + + outbound.Type = C.TypeSOCKS + outbound.SocksOptions = option.SocksOutboundOptions{ + ServerOptions: option.ServerOptions{ + Server: socks5Option.Server, + ServerPort: uint16(socks5Option.Port), + }, + Username: socks5Option.UserName, + Password: socks5Option.Password, + Network: clashNetworks(socks5Option.UDP), + } + case constant.Http: + httpOption := &clash_outbound.HttpOption{} + err = decoder.Decode(proxyMapping, httpOption) + if err != nil { + return nil, err + } + + if httpOption.TLS { + continue + } + + outbound.Type = C.TypeHTTP + outbound.HTTPOptions = option.HTTPOutboundOptions{ + ServerOptions: option.ServerOptions{ + Server: httpOption.Server, + ServerPort: uint16(httpOption.Port), + }, + Username: httpOption.UserName, + Password: httpOption.Password, + } + } + outbounds = append(outbounds, outbound) + } + if len(outbounds) > 0 { + return outbounds, nil + } + return nil, E.New("no servers found") +} + +func clashShadowsocksCipher(cipher string) string { + switch cipher { + case "dummy": + return "none" + } + return cipher +} + +func clashNetworks(udpEnabled bool) option.NetworkList { + if !udpEnabled { + return N.NetworkTCP + } + return "" +} + +func clashPluginName(plugin string) string { + switch plugin { + case "obfs": + return "obfs-local" + } + return plugin +} + +type shadowsocksPluginOptionsBuilder map[string]any + +func (o shadowsocksPluginOptionsBuilder) Build() string { + var opts []string + for key, value := range o { + if value == nil { + continue + } + opts = append(opts, format.ToString(key, "=", value)) + } + return strings.Join(opts, ";") +} + +func clashPluginOptions(plugin string, opts map[string]any) string { + options := shadowsocksPluginOptionsBuilder(opts) + switch plugin { + case "obfs": + options["mode"] = opts["mode"] + options["host"] = opts["host"] + case "v2ray-plugin": + options["mode"] = opts["mode"] + options["tls"] = opts["tls"] + options["host"] = opts["host"] + options["path"] = opts["path"] + } + return options.Build() +} + +func clashTransport(network string, httpOpts clash_outbound.HTTPOptions, h2Opts clash_outbound.HTTP2Options, grpcOpts clash_outbound.GrpcOptions, wsOpts clash_outbound.WSOptions) *option.V2RayTransportOptions { + switch network { + case "http": + var headers map[string]option.Listable[string] + for key, values := range httpOpts.Headers { + if headers == nil { + headers = make(map[string]option.Listable[string]) + } + headers[key] = values + } + return &option.V2RayTransportOptions{ + Type: C.V2RayTransportTypeHTTP, + HTTPOptions: option.V2RayHTTPOptions{ + Method: httpOpts.Method, + Path: clashStringList(httpOpts.Path), + Headers: headers, + }, + } + case "h2": + return &option.V2RayTransportOptions{ + Type: C.V2RayTransportTypeHTTP, + HTTPOptions: option.V2RayHTTPOptions{ + Path: h2Opts.Path, + Host: h2Opts.Host, + }, + } + case "grpc": + return &option.V2RayTransportOptions{ + Type: C.V2RayTransportTypeGRPC, + GRPCOptions: option.V2RayGRPCOptions{ + ServiceName: grpcOpts.GrpcServiceName, + }, + } + case "ws": + var headers map[string]option.Listable[string] + for key, value := range wsOpts.Headers { + if headers == nil { + headers = make(map[string]option.Listable[string]) + } + headers[key] = []string{value} + } + return &option.V2RayTransportOptions{ + Type: C.V2RayTransportTypeWebsocket, + WebsocketOptions: option.V2RayWebsocketOptions{ + Path: wsOpts.Path, + Headers: headers, + MaxEarlyData: uint32(wsOpts.MaxEarlyData), + EarlyDataHeaderName: wsOpts.EarlyDataHeaderName, + }, + } + default: + return nil + } +} + +func clashStringList(list []string) string { + if len(list) > 0 { + return list[0] + } + return "" +} diff --git a/subscription/parser/link.go b/subscription/parser/link.go new file mode 100644 index 0000000..05ad60f --- /dev/null +++ b/subscription/parser/link.go @@ -0,0 +1,22 @@ +package parser + +import ( + "strings" + + "github.com/sagernet/sing-box/option" + E "github.com/sagernet/sing/common/exceptions" +) + +func ParseSubscriptionLink(link string) (option.Outbound, error) { + schemeIndex := strings.Index(link, "://") + if schemeIndex == -1 { + return option.Outbound{}, E.New("not a link") + } + scheme := link[:schemeIndex] + switch scheme { + case "ss": + return ParseShadowsocksLink(link) + default: + return option.Outbound{}, E.New("unsupported scheme: ", scheme) + } +} diff --git a/subscription/parser/link_shadowsocks.go b/subscription/parser/link_shadowsocks.go new file mode 100644 index 0000000..abd70c8 --- /dev/null +++ b/subscription/parser/link_shadowsocks.go @@ -0,0 +1,67 @@ +package parser + +import ( + "net/url" + "strconv" + "strings" + + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/option" + E "github.com/sagernet/sing/common/exceptions" +) + +func ParseShadowsocksLink(link string) (option.Outbound, error) { + linkURL, err := url.Parse(link) + if err != nil { + return option.Outbound{}, err + } + + if linkURL.User == nil { + return option.Outbound{}, E.New("missing user info") + } + + var options option.ShadowsocksOutboundOptions + options.ServerOptions.Server = linkURL.Host + options.ServerOptions.ServerPort = portFromString(linkURL.Port()) + if password, _ := linkURL.User.Password(); password != "" { + options.Method = linkURL.User.Username() + options.Password = password + } else { + userAndPassword, _ := decodeBase64URLSafe(linkURL.User.Username()) + userAndPasswordParts := strings.Split(userAndPassword, ":") + if len(userAndPasswordParts) != 2 { + return option.Outbound{}, E.New("bad user info") + } + options.Method = userAndPasswordParts[0] + options.Password = userAndPasswordParts[1] + } + + plugin := linkURL.Query().Get("plugin") + options.Plugin = shadowsocksPluginName(plugin) + options.PluginOptions = shadowsocksPluginOptions(plugin) + + var outbound option.Outbound + outbound.Type = C.TypeShadowsocks + outbound.Tag = linkURL.Fragment + outbound.ShadowsocksOptions = options + return outbound, nil +} + +func portFromString(portString string) uint16 { + port, _ := strconv.ParseUint(portString, 10, 16) + return uint16(port) +} + +func shadowsocksPluginName(plugin string) string { + if index := strings.Index(plugin, ";"); index != -1 { + return plugin[:index] + } + return plugin +} + +func shadowsocksPluginOptions(plugin string) string { + if index := strings.Index(plugin, ";"); index != -1 { + return plugin[index+1:] + } + return "" +} diff --git a/subscription/parser/parser.go b/subscription/parser/parser.go new file mode 100644 index 0000000..d8fbb63 --- /dev/null +++ b/subscription/parser/parser.go @@ -0,0 +1,25 @@ +package parser + +import ( + "github.com/sagernet/sing-box/option" + E "github.com/sagernet/sing/common/exceptions" +) + +var subscriptionParsers = []func(string) ([]option.Outbound, error){ + ParseBoxSubscription, + ParseClashSubscription, + ParseSIP008Subscription, + ParseRawSubscription, +} + +func ParseSubscription(content string) ([]option.Outbound, error) { + var pErr error + for _, parser := range subscriptionParsers { + servers, err := parser(content) + if len(servers) > 0 { + return servers, nil + } + pErr = E.Errors(pErr, err) + } + return nil, E.Cause(pErr, "no servers found") +} diff --git a/subscription/parser/raw.go b/subscription/parser/raw.go new file mode 100644 index 0000000..d352cf4 --- /dev/null +++ b/subscription/parser/raw.go @@ -0,0 +1,43 @@ +package parser + +import ( + "encoding/base64" + "strings" + + "github.com/sagernet/sing-box/option" + E "github.com/sagernet/sing/common/exceptions" +) + +func ParseRawSubscription(content string) ([]option.Outbound, error) { + if base64Content, err := decodeBase64URLSafe(content); err == nil { + servers, _ := parseRawSubscription(base64Content) + if len(servers) > 0 { + return servers, err + } + } + return parseRawSubscription(content) +} + +func parseRawSubscription(content string) ([]option.Outbound, error) { + var servers []option.Outbound + content = strings.ReplaceAll(content, "\r\n", "\n") + linkList := strings.Split(content, "\n") + for _, linkLine := range linkList { + if server, err := ParseSubscriptionLink(linkLine); err == nil { + servers = append(servers, server) + } + } + if len(servers) == 0 { + return nil, E.New("no servers found") + } + return servers, nil +} + +func decodeBase64URLSafe(content string) (string, error) { + content = strings.ReplaceAll(content, " ", "-") + content = strings.ReplaceAll(content, "/", "_") + content = strings.ReplaceAll(content, "+", "-") + content = strings.ReplaceAll(content, "=", "") + result, err := base64.StdEncoding.DecodeString(content) + return string(result), err +} diff --git a/subscription/parser/sing_box.go b/subscription/parser/sing_box.go new file mode 100644 index 0000000..b95f171 --- /dev/null +++ b/subscription/parser/sing_box.go @@ -0,0 +1,28 @@ +package parser + +import ( + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/json" +) + +func ParseBoxSubscription(content string) ([]option.Outbound, error) { + options, err := json.UnmarshalExtended[option.Options]([]byte(content)) + if err != nil { + return nil, err + } + options.Outbounds = common.Filter(options.Outbounds, func(it option.Outbound) bool { + switch it.Type { + case C.TypeDirect, C.TypeBlock, C.TypeDNS, C.TypeSelector, C.TypeURLTest: + return false + default: + return true + } + }) + if len(options.Outbounds) == 0 { + return nil, E.New("no servers found") + } + return options.Outbounds, nil +} diff --git a/subscription/parser/sip008.go b/subscription/parser/sip008.go new file mode 100644 index 0000000..60a6a30 --- /dev/null +++ b/subscription/parser/sip008.go @@ -0,0 +1,51 @@ +package parser + +import ( + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/option" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/json" +) + +type ShadowsocksDocument struct { + Version int `json:"version"` + Servers []ShadowsocksServerDocument `json:"servers"` +} + +type ShadowsocksServerDocument struct { + ID string `json:"id"` + Remarks string `json:"remarks"` + Server string `json:"server"` + ServerPort int `json:"server_port"` + Password string `json:"password"` + Method string `json:"method"` + Plugin string `json:"plugin"` + PluginOpts string `json:"plugin_opts"` +} + +func ParseSIP008Subscription(content string) ([]option.Outbound, error) { + var document ShadowsocksDocument + err := json.Unmarshal([]byte(content), &document) + if err != nil { + return nil, E.Cause(err, "parse SIP008 document") + } + + var servers []option.Outbound + for _, server := range document.Servers { + servers = append(servers, option.Outbound{ + Type: C.TypeShadowsocks, + Tag: server.Remarks, + ShadowsocksOptions: option.ShadowsocksOutboundOptions{ + ServerOptions: option.ServerOptions{ + Server: server.Server, + ServerPort: uint16(server.ServerPort), + }, + Password: server.Password, + Method: server.Method, + Plugin: server.Plugin, + PluginOptions: server.PluginOpts, + }, + }) + } + return servers, nil +} diff --git a/subscription/process.go b/subscription/process.go new file mode 100644 index 0000000..2e18137 --- /dev/null +++ b/subscription/process.go @@ -0,0 +1,150 @@ +package subscription + +import ( + "regexp" + "strings" + + "github.com/sagernet/serenity/option" + boxOption "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common" + E "github.com/sagernet/sing/common/exceptions" +) + +type ProcessOptions struct { + option.OutboundProcessOptions + filter []*regexp.Regexp + exclude []*regexp.Regexp + rename []*Rename +} + +type Rename struct { + From *regexp.Regexp + To string +} + +func NewProcessOptions(options option.OutboundProcessOptions) (*ProcessOptions, error) { + var ( + filter []*regexp.Regexp + exclude []*regexp.Regexp + rename []*Rename + ) + for regexIndex, it := range options.Filter { + regex, err := regexp.Compile(it) + if err != nil { + return nil, E.Cause(err, "parse filter[", regexIndex, "]") + } + filter = append(filter, regex) + } + for regexIndex, it := range options.Exclude { + regex, err := regexp.Compile(it) + if err != nil { + return nil, E.Cause(err, "parse exclude[", regexIndex, "]") + } + exclude = append(exclude, regex) + } + if options.Rename != nil { + for renameIndex, entry := range options.Rename.Entries() { + regex, err := regexp.Compile(entry.Key) + if err != nil { + return nil, E.Cause(err, "parse rename[", renameIndex, "]: parse ", entry.Key) + } + rename = append(rename, &Rename{ + From: regex, + To: entry.Value, + }) + } + } + return &ProcessOptions{ + OutboundProcessOptions: options, + filter: filter, + exclude: exclude, + rename: rename, + }, nil +} + +func (o *ProcessOptions) Process(outbounds []boxOption.Outbound) []boxOption.Outbound { + newOutbounds := make([]boxOption.Outbound, 0, len(outbounds)) + renameResult := make(map[string]string) + for _, outbound := range outbounds { + if len(o.filter) > 0 { + if !common.Any(o.filter, func(it *regexp.Regexp) bool { + return it.MatchString(outbound.Tag) + }) { + continue + } + } + if len(o.FilterOutboundType) > 0 { + if !common.Contains(o.FilterOutboundType, outbound.Type) { + continue + } + } + if len(o.exclude) > 0 { + if common.Any(o.exclude, func(it *regexp.Regexp) bool { + return it.MatchString(outbound.Tag) + }) { + continue + } + } + if len(o.ExcludeOutboundType) > 0 { + if common.Contains(o.ExcludeOutboundType, outbound.Type) { + continue + } + } + originTag := outbound.Tag + if len(o.rename) > 0 { + for _, rename := range o.rename { + outbound.Tag = rename.From.ReplaceAllString(outbound.Tag, rename.To) + } + } + if o.RemoveEmoji { + outbound.Tag = removeEmojis(outbound.Tag) + } + outbound.Tag = strings.TrimSpace(outbound.Tag) + if originTag != outbound.Tag { + renameResult[originTag] = outbound.Tag + } + newOutbounds = append(newOutbounds, outbound) + } + if len(renameResult) > 0 { + for i, outbound := range newOutbounds { + rawOptions, err := outbound.RawOptions() + if err != nil { + continue + } + if dialerOptionsWrapper, containsDialerOptions := rawOptions.(boxOption.DialerOptionsWrapper); containsDialerOptions { + dialerOptions := dialerOptionsWrapper.TakeDialerOptions() + if dialerOptions.Detour == "" { + continue + } + newTag, loaded := renameResult[dialerOptions.Detour] + if !loaded { + continue + } + dialerOptions.Detour = newTag + dialerOptionsWrapper.ReplaceDialerOptions(dialerOptions) + newOutbounds[i] = outbound + } + } + } + return newOutbounds +} + +func removeEmojis(s string) string { + var runes []rune + for _, r := range s { + if !(r >= 0x1F600 && r <= 0x1F64F || // Emoticons + r >= 0x1F300 && r <= 0x1F5FF || // Symbols & Pictographs + r >= 0x1F680 && r <= 0x1F6FF || // Transport & Map Symbols + r >= 0x1F1E0 && r <= 0x1F1FF || // Flags + r >= 0x2600 && r <= 0x26FF || // Misc symbols + r >= 0x2700 && r <= 0x27BF || // Dingbats + r >= 0xFE00 && r <= 0xFE0F || // Variation Selectors + r >= 0x1F900 && r <= 0x1F9FF || // Supplemental Symbols and Pictographs + r >= 0x1F018 && r <= 0x1F270 || // Various asian characters + r >= 0x238C && r <= 0x2454 || // Misc items + r >= 0x20D0 && r <= 0x20FF) { // Combining Diacritical Marks for Symbols + runes = append(runes, r) + } + } + return string(runes) +} diff --git a/subscription/subscription.go b/subscription/subscription.go new file mode 100644 index 0000000..6643eb4 --- /dev/null +++ b/subscription/subscription.go @@ -0,0 +1,211 @@ +package subscription + +import ( + "context" + "io" + "net/http" + "time" + + "github.com/sagernet/serenity/common/cachefile" + C "github.com/sagernet/serenity/constant" + "github.com/sagernet/serenity/option" + "github.com/sagernet/serenity/subscription/parser" + boxOption "github.com/sagernet/sing-box/option" + E "github.com/sagernet/sing/common/exceptions" + F "github.com/sagernet/sing/common/format" + "github.com/sagernet/sing/common/logger" +) + +type Manager struct { + ctx context.Context + cancel context.CancelFunc + logger logger.Logger + cacheFile *cachefile.CacheFile + subscriptions []*Subscription + updateInterval time.Duration + updateTicker *time.Ticker + httpClient http.Client +} + +type Subscription struct { + option.Subscription + rawServers []boxOption.Outbound + processes []*ProcessOptions + Servers []boxOption.Outbound + LastUpdated time.Time + LastEtag string +} + +func NewSubscriptionManager(ctx context.Context, logger logger.Logger, cacheFile *cachefile.CacheFile, rawSubscriptions []option.Subscription) (*Manager, error) { + var ( + subscriptions []*Subscription + interval time.Duration + ) + for index, subscription := range rawSubscriptions { + if subscription.Name == "" { + return nil, E.New("initialize subscription[", index, "]: missing name") + } + var processes []*ProcessOptions + if interval == 0 || time.Duration(subscription.UpdateInterval) < interval { + interval = time.Duration(subscription.UpdateInterval) + } + for processIndex, process := range subscription.Process { + processOptions, err := NewProcessOptions(process) + if err != nil { + return nil, E.Cause(err, "initialize subscription[", subscription.Name, "]: parse process[", processIndex, "]") + } + processes = append(processes, processOptions) + } + subscriptions = append(subscriptions, &Subscription{ + Subscription: subscription, + processes: processes, + }) + } + if interval == 0 { + interval = option.DefaultSubscriptionUpdateInterval + } + ctx, cancel := context.WithCancel(ctx) + return &Manager{ + ctx: ctx, + cancel: cancel, + logger: logger, + cacheFile: cacheFile, + subscriptions: subscriptions, + updateInterval: interval, + }, nil +} + +func (m *Manager) Start() error { + for _, subscription := range m.subscriptions { + savedSubscription := m.cacheFile.LoadSubscription(subscription.Name) + if savedSubscription != nil { + subscription.rawServers = savedSubscription.Content + subscription.LastUpdated = savedSubscription.LastUpdated + subscription.LastEtag = savedSubscription.LastEtag + m.processSubscription(subscription, false) + } + } + return nil +} + +func (m *Manager) processSubscription(s *Subscription, onUpdate bool) { + servers := s.rawServers + for _, process := range s.processes { + servers = process.Process(servers) + } + if s.DeDuplication { + originLen := len(servers) + servers = Deduplication(m.ctx, servers) + if onUpdate && originLen != len(servers) { + m.logger.Info("excluded ", originLen-len(servers), " duplicated servers in ", s.Name) + } + } + s.Servers = servers +} + +func (m *Manager) PostStart() error { + m.updateAll() + m.updateTicker = time.NewTicker(m.updateInterval) + go m.loopUpdate() + return nil +} + +func (m *Manager) Close() error { + if m.updateTicker != nil { + m.updateTicker.Stop() + } + m.cancel() + m.httpClient.CloseIdleConnections() + return nil +} + +func (m *Manager) Subscriptions() []*Subscription { + return m.subscriptions +} + +func (m *Manager) loopUpdate() { + for { + select { + case <-m.updateTicker.C: + m.updateAll() + case <-m.ctx.Done(): + return + } + } +} + +func (m *Manager) updateAll() { + for _, subscription := range m.subscriptions { + if time.Since(subscription.LastUpdated) < m.updateInterval { + continue + } + err := m.update(subscription) + if err != nil { + m.logger.Error(E.Cause(err, "update subscription ", subscription.Name)) + } + } +} + +func (m *Manager) update(subscription *Subscription) error { + request, err := http.NewRequest("GET", subscription.URL, nil) + if err != nil { + return err + } + if subscription.UserAgent != "" { + request.Header.Set("User-Agent", subscription.UserAgent) + } else { + request.Header.Set("User-Agent", F.ToString("serenity/", C.Version, " (sing-box ", C.CoreVersion(), "; Clash compatible)")) + } + if subscription.LastEtag != "" { + request.Header.Set("If-None-Match", subscription.LastEtag) + } + response, err := m.httpClient.Do(request.WithContext(m.ctx)) + if err != nil { + return err + } + switch response.StatusCode { + case http.StatusOK: + case http.StatusNotModified: + subscription.LastUpdated = time.Now() + err = m.cacheFile.StoreSubscription(subscription.Name, &cachefile.Subscription{ + Content: subscription.rawServers, + LastUpdated: subscription.LastUpdated, + LastEtag: subscription.LastEtag, + }) + if err != nil { + return err + } + m.logger.Info("updated subscription ", subscription.Name, ": not modified") + return nil + default: + return E.New("unexpected status: ", response.Status) + } + content, err := io.ReadAll(response.Body) + if err != nil { + response.Body.Close() + return err + } + rawServers, err := parser.ParseSubscription(string(content)) + if err != nil { + response.Body.Close() + return err + } + response.Body.Close() + subscription.rawServers = rawServers + m.processSubscription(subscription, true) + eTagHeader := response.Header.Get("Etag") + if eTagHeader != "" { + subscription.LastEtag = eTagHeader + } + subscription.LastUpdated = time.Now() + err = m.cacheFile.StoreSubscription(subscription.Name, &cachefile.Subscription{ + Content: subscription.rawServers, + LastUpdated: subscription.LastUpdated, + LastEtag: subscription.LastEtag, + }) + if err != nil { + return err + } + m.logger.Info("updated subscription ", subscription.Name, ": ", len(subscription.rawServers), " servers") + return nil +} diff --git a/template/filter/filter.go b/template/filter/filter.go new file mode 100644 index 0000000..7664365 --- /dev/null +++ b/template/filter/filter.go @@ -0,0 +1,16 @@ +package filter + +import ( + "github.com/sagernet/serenity/common/metadata" + boxOption "github.com/sagernet/sing-box/option" +) + +type OptionsFilter func(metadata metadata.Metadata, options *boxOption.Options) + +var filters []OptionsFilter + +func Filter(metadata metadata.Metadata, options *boxOption.Options) { + for _, filter := range filters { + filter(metadata, options) + } +} diff --git a/template/filter/filter_170.go b/template/filter/filter_170.go new file mode 100644 index 0000000..9b7ece1 --- /dev/null +++ b/template/filter/filter_170.go @@ -0,0 +1,90 @@ +package filter + +import ( + "github.com/sagernet/serenity/common/metadata" + "github.com/sagernet/serenity/common/semver" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common" +) + +func init() { + filters = append(filters, filter170) +} + +func filter170(metadata metadata.Metadata, options *option.Options) { + if metadata.Version == nil || metadata.Version.GreaterThanOrEqual(semver.Version{Major: 1, Minor: 7}) { + return + } + newInbounds := make([]option.Inbound, 0, len(options.Inbounds)) + for _, inbound := range options.Inbounds { + switch inbound.Type { + case C.TypeTun: + inbound.TunOptions = filter170Tun(inbound.TunOptions) + inbound.TunOptions.InboundOptions = filter170InboundOptions(inbound.TunOptions.InboundOptions) + case C.TypeRedirect: + inbound.RedirectOptions.InboundOptions = filter170InboundOptions(inbound.RedirectOptions.InboundOptions) + case C.TypeTProxy: + inbound.TProxyOptions.InboundOptions = filter170InboundOptions(inbound.TProxyOptions.InboundOptions) + case C.TypeDirect: + inbound.DirectOptions.InboundOptions = filter170InboundOptions(inbound.DirectOptions.InboundOptions) + case C.TypeSOCKS: + inbound.SocksOptions.InboundOptions = filter170InboundOptions(inbound.SocksOptions.InboundOptions) + case C.TypeHTTP: + inbound.HTTPOptions.InboundOptions = filter170InboundOptions(inbound.HTTPOptions.InboundOptions) + case C.TypeMixed: + inbound.MixedOptions.InboundOptions = filter170InboundOptions(inbound.MixedOptions.InboundOptions) + case C.TypeShadowsocks: + inbound.ShadowsocksOptions.InboundOptions = filter170InboundOptions(inbound.ShadowsocksOptions.InboundOptions) + inbound.ShadowsocksOptions.Multiplex = nil + case C.TypeVMess: + inbound.VMessOptions.InboundOptions = filter170InboundOptions(inbound.VMessOptions.InboundOptions) + inbound.VMessOptions.Multiplex = nil + case C.TypeTrojan: + inbound.TrojanOptions.InboundOptions = filter170InboundOptions(inbound.TrojanOptions.InboundOptions) + inbound.TrojanOptions.Multiplex = nil + case C.TypeNaive: + inbound.NaiveOptions.InboundOptions = filter170InboundOptions(inbound.NaiveOptions.InboundOptions) + case C.TypeHysteria: + inbound.HysteriaOptions.InboundOptions = filter170InboundOptions(inbound.HysteriaOptions.InboundOptions) + case C.TypeShadowTLS: + inbound.ShadowTLSOptions.InboundOptions = filter170InboundOptions(inbound.ShadowTLSOptions.InboundOptions) + case C.TypeVLESS: + inbound.VLESSOptions.InboundOptions = filter170InboundOptions(inbound.VLESSOptions.InboundOptions) + inbound.VLESSOptions.Multiplex = nil + case C.TypeTUIC: + inbound.TUICOptions.InboundOptions = filter170InboundOptions(inbound.TUICOptions.InboundOptions) + case C.TypeHysteria2: + inbound.Hysteria2Options.InboundOptions = filter170InboundOptions(inbound.Hysteria2Options.InboundOptions) + default: + continue + } + newInbounds = append(newInbounds, inbound) + } + options.Inbounds = newInbounds + if options.Route != nil { + options.Route.Rules = common.Filter(options.Route.Rules, filter170Rule) + } + if options.DNS != nil { + options.DNS.Rules = common.Filter(options.DNS.Rules, filter170DNSRule) + } +} + +func filter170Tun(options option.TunInboundOptions) option.TunInboundOptions { + options.Inet4RouteExcludeAddress = nil + options.Inet6RouteExcludeAddress = nil + return options +} + +func filter170InboundOptions(options option.InboundOptions) option.InboundOptions { + options.UDPDisableDomainUnmapping = false + return options +} + +func filter170Rule(it option.Rule) bool { + return !hasRule([]option.Rule{it}, isWIFIRule) +} + +func filter170DNSRule(it option.DNSRule) bool { + return !hasDNSRule([]option.DNSRule{it}, isWIFIDNSRule) +} diff --git a/template/filter/filter_180.go b/template/filter/filter_180.go new file mode 100644 index 0000000..9954f06 --- /dev/null +++ b/template/filter/filter_180.go @@ -0,0 +1,58 @@ +package filter + +import ( + "github.com/sagernet/serenity/common/metadata" + "github.com/sagernet/serenity/common/semver" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common" +) + +func init() { + filters = append(filters, filter180) +} + +func filter180(metadata metadata.Metadata, options *option.Options) { + if metadata.Version == nil || metadata.Version.GreaterThanOrEqual(semver.ParseVersion("1.8.0-alpha.10")) { + return + } + for index, outbound := range options.Outbounds { + switch outbound.Type { + case C.TypeURLTest: + options.Outbounds[index].URLTestOptions = filter180a10URLTest(outbound.URLTestOptions) + } + } + if metadata.Version.GreaterThanOrEqual(semver.ParseVersion("1.8.0-alpha.5")) { + return + } + options.Route.RuleSet = nil + if options.Route != nil { + options.Route.Rules = common.Filter(options.Route.Rules, filter180Rule) + } + if options.DNS != nil { + options.DNS.Rules = common.Filter(options.DNS.Rules, filter180DNSRule) + } + if metadata.Version.GreaterThanOrEqual(semver.ParseVersion("1.8.0-alpha.1")) { + return + } + if options.Route != nil { + options.Route.Rules = common.Filter(options.Route.Rules, filter180a5Rule) + } +} + +func filter180Rule(it option.Rule) bool { + return !hasRule([]option.Rule{it}, isRuleSetRule) +} + +func filter180DNSRule(it option.DNSRule) bool { + return !hasDNSRule([]option.DNSRule{it}, isRuleSetDNSRule) +} + +func filter180a5Rule(it option.Rule) bool { + return !hasRule([]option.Rule{it}, isIPIsPrivateRule) +} + +func filter180a10URLTest(options option.URLTestOutboundOptions) option.URLTestOutboundOptions { + options.IdleTimeout = 0 + return options +} diff --git a/template/filter/filter_rule.go b/template/filter/filter_rule.go new file mode 100644 index 0000000..df5e34c --- /dev/null +++ b/template/filter/filter_rule.go @@ -0,0 +1,58 @@ +package filter + +import ( + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/option" +) + +func hasRule(rules []option.Rule, cond func(rule option.DefaultRule) bool) bool { + for _, rule := range rules { + switch rule.Type { + case C.RuleTypeDefault: + if cond(rule.DefaultOptions) { + return true + } + case C.RuleTypeLogical: + if hasRule(rule.LogicalOptions.Rules, cond) { + return true + } + } + } + return false +} + +func hasDNSRule(rules []option.DNSRule, cond func(rule option.DefaultDNSRule) bool) bool { + for _, rule := range rules { + switch rule.Type { + case C.RuleTypeDefault: + if cond(rule.DefaultOptions) { + return true + } + case C.RuleTypeLogical: + if hasDNSRule(rule.LogicalOptions.Rules, cond) { + return true + } + } + } + return false +} + +func isWIFIRule(rule option.DefaultRule) bool { + return len(rule.WIFISSID) > 0 || len(rule.WIFIBSSID) > 0 +} + +func isWIFIDNSRule(rule option.DefaultDNSRule) bool { + return len(rule.WIFISSID) > 0 || len(rule.WIFIBSSID) > 0 +} + +func isRuleSetRule(rule option.DefaultRule) bool { + return len(rule.RuleSet) > 0 +} + +func isRuleSetDNSRule(rule option.DefaultDNSRule) bool { + return len(rule.RuleSet) > 0 +} + +func isIPIsPrivateRule(rule option.DefaultRule) bool { + return rule.IPIsPrivate +} diff --git a/template/manager.go b/template/manager.go new file mode 100644 index 0000000..aacc682 --- /dev/null +++ b/template/manager.go @@ -0,0 +1,80 @@ +package template + +import ( + "context" + "regexp" + + "github.com/sagernet/serenity/option" + C "github.com/sagernet/sing-box/constant" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/logger" +) + +type Manager struct { + ctx context.Context + logger logger.Logger + templates []*Template +} + +func NewManager(ctx context.Context, logger logger.Logger, rawTemplates []option.Template) (*Manager, error) { + var templates []*Template + for templateIndex, template := range rawTemplates { + if template.Name == "" { + return nil, E.New("initialize template[", templateIndex, "]: missing name") + } + var groups []*ExtraGroup + for groupIndex, group := range template.ExtraGroups { + if group.Tag == "" { + return nil, E.New("initialize template[", template.Name, "]: extra_group[", groupIndex, "]: missing tag") + } + switch group.Type { + case C.TypeSelector, C.TypeURLTest: + case "": + return nil, E.New("initialize template[", template.Name, "]: extra_group[", group.Tag, "]: missing type") + default: + return nil, E.New("initialize template[", template.Name, "]: extra_group[", group.Tag, "]: invalid group type: ", group.Type) + } + var ( + filter []*regexp.Regexp + exclude []*regexp.Regexp + ) + for filterIndex, it := range group.Filter { + regex, err := regexp.Compile(it) + if err != nil { + return nil, E.Cause(err, "initialize template[", template.Name, "]: parse extra_group[", group.Tag, "]: parse filter[", filterIndex, "]: ", it) + } + filter = append(filter, regex) + } + for excludeIndex, it := range group.Exclude { + regex, err := regexp.Compile(it) + if err != nil { + return nil, E.Cause(err, "initialize template[", template.Name, "]: parse extra_group[", group.Tag, "]: parse exclude[", excludeIndex, "]: ", it) + } + exclude = append(exclude, regex) + } + groups = append(groups, &ExtraGroup{ + ExtraGroup: group, + filter: filter, + exclude: exclude, + }) + } + templates = append(templates, &Template{ + Template: template, + groups: groups, + }) + } + return &Manager{ + ctx: ctx, + logger: logger, + templates: templates, + }, nil +} + +func (m *Manager) TemplateByName(name string) *Template { + for _, template := range m.templates { + if template.Name == name { + return template + } + } + return nil +} diff --git a/template/render_dns.go b/template/render_dns.go new file mode 100644 index 0000000..ce93a19 --- /dev/null +++ b/template/render_dns.go @@ -0,0 +1,186 @@ +package template + +import ( + "net/netip" + "net/url" + + M "github.com/sagernet/serenity/common/metadata" + "github.com/sagernet/serenity/common/semver" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-dns" + "github.com/sagernet/sing/common" + BM "github.com/sagernet/sing/common/metadata" + + mDNS "github.com/miekg/dns" +) + +func (t *Template) renderDNS(metadata M.Metadata, options *option.Options) error { + var domainStrategy option.DomainStrategy + if t.DomainStrategy != option.DomainStrategy(dns.DomainStrategyAsIS) { + domainStrategy = t.DomainStrategy + } else { + domainStrategy = option.DomainStrategy(dns.DomainStrategyPreferIPv4) + } + options.DNS = &option.DNSOptions{ + ReverseMapping: !t.DisableTrafficBypass && !metadata.Platform.IsApple(), + DNSClientOptions: option.DNSClientOptions{ + Strategy: domainStrategy, + IndependentCache: t.EnableFakeIP, + }, + } + dnsDefault := t.DNSDefault + if dnsDefault == "" { + dnsDefault = DefaultDNS + } + dnsLocal := t.DNSLocal + if dnsLocal == "" { + dnsLocal = DefaultDNSLocal + } + defaultDNSOptions := option.DNSServerOptions{ + Tag: DNSDefaultTag, + Address: dnsDefault, + } + if dnsDefaultUrl, err := url.Parse(dnsDefault); err == nil && BM.IsDomainName(dnsDefaultUrl.Hostname()) { + defaultDNSOptions.AddressResolver = DNSLocalTag + } + options.DNS.Servers = append(options.DNS.Servers, defaultDNSOptions) + var ( + localDNSOptions option.DNSServerOptions + localDNSIsDomain bool + ) + if t.DisableTrafficBypass { + localDNSOptions = option.DNSServerOptions{ + Tag: DNSLocalTag, + Address: "local", + } + } else { + localDNSOptions = option.DNSServerOptions{ + Tag: DNSLocalTag, + Address: dnsLocal, + Detour: DefaultDirectTag, + } + if dnsLocalUrl, err := url.Parse(dnsLocal); err == nil && BM.IsDomainName(dnsLocalUrl.Hostname()) { + localDNSOptions.AddressResolver = DNSLocalSetupTag + localDNSIsDomain = true + } + } + options.DNS.Servers = append(options.DNS.Servers, localDNSOptions) + if localDNSIsDomain { + options.DNS.Servers = append(options.DNS.Servers, option.DNSServerOptions{ + Tag: DNSLocalSetupTag, + Address: "local", + }) + } + if t.EnableFakeIP { + options.DNS.FakeIP = &option.DNSFakeIPOptions{ + Enabled: true, + Inet4Range: common.Ptr(netip.MustParsePrefix("198.18.0.0/15")), + } + if !t.DisableIPv6() { + options.DNS.FakeIP.Inet6Range = common.Ptr(netip.MustParsePrefix("fc00::/18")) + } + options.DNS.Servers = append(options.DNS.Servers, option.DNSServerOptions{ + Tag: DNSFakeIPTag, + Address: "fakeip", + }) + } + options.DNS.Rules = []option.DNSRule{ + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + Outbound: []string{"any"}, + Server: DNSLocalTag, + }, + }, + } + if !t.DisableClashMode { + options.DNS.Rules = append(options.DNS.Rules, option.DNSRule{ + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + ClashMode: "Direct", + Server: DNSLocalTag, + }, + }, option.DNSRule{ + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + ClashMode: "Global", + Server: DNSDefaultTag, + }, + }) + } + options.DNS.Rules = append(options.DNS.Rules, t.PreDNSRules...) + if len(t.CustomDNSRules) == 0 { + if !t.DisableTrafficBypass { + if t.DisableRuleSet || (metadata.Version == nil || metadata.Version.LessThan(semver.ParseVersion("1.8.0-alpha.10"))) { + options.DNS.Rules = append(options.DNS.Rules, option.DNSRule{ + Type: C.RuleTypeLogical, + LogicalOptions: option.LogicalDNSRule{ + Mode: C.LogicalTypeAnd, + Rules: []option.DNSRule{ + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + Geosite: []string{"geolocation-!cn"}, + Invert: true, + }, + }, + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + Geosite: []string{ + "cn", + "category-companies@cn", + }, + }, + }, + }, + Server: DNSLocalTag, + }, + }) + } else { + options.DNS.Rules = append(options.DNS.Rules, option.DNSRule{ + Type: C.RuleTypeLogical, + LogicalOptions: option.LogicalDNSRule{ + Mode: C.LogicalTypeAnd, + Rules: []option.DNSRule{ + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + RuleSet: []string{"geosite-geolocation-!cn"}, + Invert: true, + }, + }, + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + RuleSet: []string{ + "geosite-cn", + "geosite-category-companies@cn", + }, + DomainSuffix: []string{"download.jetbrains.com"}, + }, + }, + }, + Server: DNSLocalTag, + }, + }) + } + } + } else { + options.DNS.Rules = append(options.DNS.Rules, t.CustomDNSRules...) + } + if t.EnableFakeIP { + options.DNS.Rules = append(options.DNS.Rules, option.DNSRule{ + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + QueryType: []option.DNSQueryType{ + option.DNSQueryType(mDNS.TypeA), + option.DNSQueryType(mDNS.TypeAAAA), + }, + Server: DNSFakeIPTag, + }, + }) + } + return nil +} diff --git a/template/render_experimental.go b/template/render_experimental.go new file mode 100644 index 0000000..f13b968 --- /dev/null +++ b/template/render_experimental.go @@ -0,0 +1,66 @@ +package template + +import ( + M "github.com/sagernet/serenity/common/metadata" + "github.com/sagernet/serenity/common/semver" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common" + "github.com/sagernet/sing/common/json/badjson" +) + +func (t *Template) renderExperimental(metadata M.Metadata, options *option.Options, profileName string) error { + if t.DisableCacheFile && t.DisableClashMode && t.CustomClashAPI == nil { + return nil + } + options.Experimental = &option.ExperimentalOptions{} + disable18Features := metadata.Version == nil || metadata.Version.LessThan(semver.ParseVersion("1.8.0-alpha.10")) + if !t.DisableCacheFile { + if disable18Features { + //nolint:staticcheck + //goland:noinspection GoDeprecation + options.Experimental.ClashAPI = &option.ClashAPIOptions{ + CacheID: profileName, + StoreMode: true, + StoreSelected: true, + StoreFakeIP: t.EnableFakeIP, + } + } else { + options.Experimental.CacheFile = &option.CacheFileOptions{ + Enabled: true, + CacheID: profileName, + StoreFakeIP: t.EnableFakeIP, + } + } + } + + if t.CustomClashAPI != nil { + newClashOptions, err := badjson.MergeFromDestination(options.Experimental.ClashAPI, t.CustomClashAPI.Message) + if err != nil { + return err + } + options.Experimental.ClashAPI = newClashOptions + } else { + if options.Experimental.ClashAPI == nil { + options.Experimental.ClashAPI = &option.ClashAPIOptions{} + } + options.Experimental.ClashAPI.ExternalController = "127.0.0.1:9090" + } + + if !t.DisableClashMode { + options.Experimental.ClashAPI.DefaultMode = t.ClashModeRule + } + if t.PProfListen != "" { + if options.Experimental.Debug == nil { + options.Experimental.Debug = &option.DebugOptions{} + } + options.Experimental.Debug.Listen = t.PProfListen + } + if t.MemoryLimit > 0 && !metadata.Platform.IsNetworkExtensionMemoryLimited() { + if options.Experimental.Debug == nil { + options.Experimental.Debug = &option.DebugOptions{} + } + options.Experimental.Debug.MemoryLimit = t.MemoryLimit + options.Experimental.Debug.OOMKiller = common.Ptr(true) + } + return nil +} diff --git a/template/render_geo_resources.go b/template/render_geo_resources.go new file mode 100644 index 0000000..afbfaa2 --- /dev/null +++ b/template/render_geo_resources.go @@ -0,0 +1,99 @@ +package template + +import ( + M "github.com/sagernet/serenity/common/metadata" + "github.com/sagernet/serenity/common/semver" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/option" +) + +func (t *Template) renderGeoResources(metadata M.Metadata, options *option.Options) { + if t.DisableRuleSet || (metadata.Version == nil || metadata.Version.LessThan(semver.ParseVersion("1.8.0-alpha.10"))) { + var ( + geoipDownloadURL string + geositeDownloadURL string + downloadDetour string + ) + if t.EnableJSDelivr { + geoipDownloadURL = "https://testingcf.jsdelivr.net/gh/SagerNet/sing-geoip@release/geoip-cn.db" + geositeDownloadURL = "https://testingcf.jsdelivr.net/gh/SagerNet/sing-geosite@release/geosite-cn.db" + if t.DirectTag != "" { + downloadDetour = t.DirectTag + } else { + downloadDetour = DefaultDirectTag + } + } else { + geoipDownloadURL = "https://github.com/SagerNet/sing-geoip/releases/latest/download/geoip-cn.db" + geositeDownloadURL = "https://github.com/SagerNet/sing-geosite/releases/latest/download/geosite-cn.db" + } + if t.CustomGeoIP == nil { + options.Route.GeoIP = &option.GeoIPOptions{ + DownloadURL: geoipDownloadURL, + DownloadDetour: downloadDetour, + } + } + if t.CustomGeosite == nil { + options.Route.Geosite = &option.GeositeOptions{ + DownloadURL: geositeDownloadURL, + DownloadDetour: downloadDetour, + } + } + } else if len(t.CustomDNSRules) == 0 { + var ( + downloadURL string + downloadDetour string + branchSplit string + ) + if t.EnableJSDelivr { + downloadURL = "https://testingcf.jsdelivr.net/gh/" + if t.DirectTag != "" { + downloadDetour = t.DirectTag + } else { + downloadDetour = DefaultDirectTag + } + branchSplit = "@" + } else { + downloadURL = "https://raw.githubusercontent.com/" + branchSplit = "/" + } + + options.Route.RuleSet = []option.RuleSet{ + { + Type: C.RuleSetTypeRemote, + Tag: "geoip-cn", + Format: C.RuleSetFormatBinary, + RemoteOptions: option.RemoteRuleSet{ + URL: downloadURL + "SagerNet/sing-geoip" + branchSplit + "rule-set/geoip-cn.srs", + DownloadDetour: downloadDetour, + }, + }, + { + Type: C.RuleSetTypeRemote, + Tag: "geosite-cn", + Format: C.RuleSetFormatBinary, + RemoteOptions: option.RemoteRuleSet{ + URL: downloadURL + "SagerNet/sing-geosite" + branchSplit + "rule-set/geosite-cn.srs", + DownloadDetour: downloadDetour, + }, + }, + { + Type: C.RuleSetTypeRemote, + Tag: "geosite-geolocation-!cn", + Format: C.RuleSetFormatBinary, + RemoteOptions: option.RemoteRuleSet{ + URL: downloadURL + "SagerNet/sing-geosite" + branchSplit + "rule-set/geosite-geolocation-!cn.srs", + DownloadDetour: downloadDetour, + }, + }, + { + Type: C.RuleSetTypeRemote, + Tag: "geosite-category-companies@cn", + Format: C.RuleSetFormatBinary, + RemoteOptions: option.RemoteRuleSet{ + URL: downloadURL + "SagerNet/sing-geosite" + branchSplit + "rule-set/geosite-category-companies@cn.srs", + DownloadDetour: downloadDetour, + }, + }, + } + } +} diff --git a/template/render_inbounds.go b/template/render_inbounds.go new file mode 100644 index 0000000..8c20d4f --- /dev/null +++ b/template/render_inbounds.go @@ -0,0 +1,107 @@ +package template + +import ( + "net/netip" + + M "github.com/sagernet/serenity/common/metadata" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-dns" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/json/badjson" +) + +func (t *Template) renderInbounds(metadata M.Metadata, options *option.Options) error { + var needSniff bool + if !t.DisableTrafficBypass { + needSniff = true + } + var domainStrategy option.DomainStrategy + if !t.RemoteResolve { + if t.DomainStrategy != option.DomainStrategy(dns.DomainStrategyAsIS) { + domainStrategy = t.DomainStrategy + } else { + domainStrategy = option.DomainStrategy(dns.DomainStrategyPreferIPv4) + } + } + disableTun := t.DisableTUN && !metadata.Platform.TunOnly() + if !disableTun { + if options.Route == nil { + options.Route = &option.RouteOptions{} + } + options.Route.AutoDetectInterface = true + + var inet6Address []netip.Prefix + if !t.DisableIPv6() { + inet6Address = []netip.Prefix{netip.MustParsePrefix("fdfe:dcba:9876::1/126")} + } + tunInbound := option.Inbound{ + Type: C.TypeTun, + TunOptions: option.TunInboundOptions{ + Inet4Address: []netip.Prefix{netip.MustParsePrefix("172.19.0.1/30")}, + Inet6Address: inet6Address, + AutoRoute: true, + InboundOptions: option.InboundOptions{ + SniffEnabled: needSniff, + }, + }, + } + if t.EnableFakeIP { + tunInbound.TunOptions.InboundOptions.DomainStrategy = domainStrategy + } + if metadata.Platform == M.PlatformUnknown { + tunInbound.TunOptions.StrictRoute = true + } + if !t.DisableSystemProxy && metadata.Platform != M.PlatformUnknown { + var httpPort uint16 + if t.CustomMixed != nil { + httpPort = t.CustomMixed.Value.ListenPort + } + if httpPort == 0 { + httpPort = DefaultMixedPort + } + tunInbound.TunOptions.Platform = &option.TunPlatformOptions{ + HTTPProxy: &option.HTTPProxyOptions{ + Enabled: true, + ServerOptions: option.ServerOptions{ + Server: "127.0.0.1", + ServerPort: httpPort, + }, + }, + } + } + if t.CustomTUN != nil { + newTUNOptions, err := badjson.MergeFromDestination(tunInbound.TunOptions, t.CustomTUN.Message) + if err != nil { + return E.Cause(err, "merge custom tun options") + } + tunInbound.TunOptions = newTUNOptions + } + options.Inbounds = append(options.Inbounds, tunInbound) + } + if disableTun || !t.DisableSystemProxy { + mixedInbound := option.Inbound{ + Type: C.TypeMixed, + MixedOptions: option.HTTPMixedInboundOptions{ + ListenOptions: option.ListenOptions{ + Listen: option.NewListenAddress(netip.AddrFrom4([4]byte{127, 0, 0, 1})), + ListenPort: DefaultMixedPort, + InboundOptions: option.InboundOptions{ + SniffEnabled: needSniff, + DomainStrategy: domainStrategy, + }, + }, + SetSystemProxy: metadata.Platform == M.PlatformUnknown && disableTun && !t.DisableSystemProxy, + }, + } + if t.CustomMixed != nil { + newMixedOptions, err := badjson.MergeFromDestination(mixedInbound.MixedOptions, t.CustomMixed.Message) + if err != nil { + return E.Cause(err, "merge custom mixed options") + } + mixedInbound.MixedOptions = newMixedOptions + } + options.Inbounds = append(options.Inbounds, mixedInbound) + } + return nil +} diff --git a/template/render_outbounds.go b/template/render_outbounds.go new file mode 100644 index 0000000..86dff63 --- /dev/null +++ b/template/render_outbounds.go @@ -0,0 +1,183 @@ +package template + +import ( + "regexp" + + M "github.com/sagernet/serenity/common/metadata" + "github.com/sagernet/serenity/subscription" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common" +) + +func (t *Template) renderOutbounds(metadata M.Metadata, options *option.Options, outbounds [][]option.Outbound, subscriptions []*subscription.Subscription) error { + defaultTag := t.DefaultTag + if defaultTag == "" { + defaultTag = DefaultDefaultTag + } + if options.Route == nil { + options.Route = &option.RouteOptions{} + } + options.Route.Final = defaultTag + directTag := t.DirectTag + if directTag == "" { + directTag = DefaultDirectTag + } + options.Outbounds = []option.Outbound{ + { + Tag: directTag, + Type: C.TypeDirect, + DirectOptions: common.PtrValueOrDefault(t.CustomDirect), + }, + { + Tag: BlockTag, + Type: C.TypeBlock, + }, + { + Tag: DNSTag, + Type: C.TypeDNS, + }, + { + Tag: defaultTag, + Type: C.TypeSelector, + SelectorOptions: common.PtrValueOrDefault(t.CustomSelector), + }, + } + urlTestTag := t.URLTestTag + if urlTestTag == "" { + urlTestTag = DefaultURLTestTag + } + if t.GenerateGlobalURLTest { + options.Outbounds = append(options.Outbounds, option.Outbound{ + Tag: urlTestTag, + Type: C.TypeURLTest, + URLTestOptions: common.PtrValueOrDefault(t.CustomURLTest), + }) + } + globalJoin := func(groupOutbounds ...string) { + options.Outbounds = groupJoin(options.Outbounds, defaultTag, groupOutbounds...) + if t.GenerateGlobalURLTest { + options.Outbounds = groupJoin(options.Outbounds, urlTestTag, groupOutbounds...) + } + } + + var globalOutbounds []option.Outbound + if len(outbounds) > 0 { + for _, outbound := range outbounds { + options.Outbounds = append(options.Outbounds, outbound...) + } + globalOutbounds = common.Map(outbounds, func(it []option.Outbound) option.Outbound { + return it[0] + }) + globalJoin(common.Map(globalOutbounds, func(it option.Outbound) string { + return it.Tag + })...) + } + + var allGroups []option.Outbound + var allGroupOutbounds []option.Outbound + + for _, it := range subscriptions { + if len(it.Servers) == 0 { + continue + } + joinOutbounds := common.Map(it.Servers, func(it option.Outbound) string { + return it.Tag + }) + if it.GenerateSelector { + selectorOutbound := option.Outbound{ + Type: C.TypeSelector, + Tag: it.Name, + SelectorOptions: common.PtrValueOrDefault(t.CustomSelector), + } + selectorOutbound.SelectorOptions.Outbounds = append(selectorOutbound.SelectorOptions.Outbounds, joinOutbounds...) + allGroups = append(allGroups, selectorOutbound) + globalJoin(it.Name) + } + if it.GenerateURLTest { + var urltestTag string + if !it.GenerateSelector { + urltestTag = it.Name + } else if it.URLTestTagSuffix != "" { + urltestTag = it.Name + " " + it.URLTestTagSuffix + } else { + urltestTag = it.Name + " - URLTest" + } + urltestOutbound := option.Outbound{ + Type: C.TypeURLTest, + Tag: urltestTag, + URLTestOptions: common.PtrValueOrDefault(t.CustomURLTest), + } + urltestOutbound.URLTestOptions.Outbounds = append(urltestOutbound.URLTestOptions.Outbounds, joinOutbounds...) + allGroups = append(allGroups, urltestOutbound) + globalJoin(urltestTag) + } + if !it.GenerateSelector && !it.GenerateURLTest { + globalJoin(joinOutbounds...) + } + allGroupOutbounds = append(allGroupOutbounds, it.Servers...) + } + + globalOutbounds = append(globalOutbounds, allGroups...) + globalOutbounds = append(globalOutbounds, allGroupOutbounds...) + + for _, group := range t.groups { + var extraTags []string + for _, groupOutbound := range globalOutbounds { + if len(group.filter) > 0 { + if !common.Any(group.filter, func(it *regexp.Regexp) bool { + return it.MatchString(groupOutbound.Tag) + }) { + continue + } + } + if len(group.exclude) > 0 { + if common.Any(group.exclude, func(it *regexp.Regexp) bool { + return it.MatchString(groupOutbound.Tag) + }) { + continue + } + } + extraTags = append(extraTags, groupOutbound.Tag) + } + if len(extraTags) == 0 { + continue + } + groupOutbound := option.Outbound{ + Tag: group.Tag, + Type: group.Type, + SelectorOptions: common.PtrValueOrDefault(group.CustomSelector), + URLTestOptions: common.PtrValueOrDefault(group.CustomURLTest), + } + switch group.Type { + case C.TypeSelector: + groupOutbound.SelectorOptions.Outbounds = extraTags + case C.TypeURLTest: + groupOutbound.URLTestOptions.Outbounds = extraTags + } + options.Outbounds = append(options.Outbounds, groupOutbound) + } + + options.Outbounds = append(options.Outbounds, allGroups...) + options.Outbounds = append(options.Outbounds, allGroupOutbounds...) + + return nil +} + +func groupJoin(outbounds []option.Outbound, groupTag string, groupOutbounds ...string) []option.Outbound { + groupIndex := common.Index(outbounds, func(it option.Outbound) bool { + return it.Tag == groupTag + }) + if groupIndex == -1 { + return outbounds + } + groupOutbound := outbounds[groupIndex] + switch groupOutbound.Type { + case C.TypeSelector: + groupOutbound.SelectorOptions.Outbounds = append(groupOutbound.SelectorOptions.Outbounds, groupOutbounds...) + case C.TypeURLTest: + groupOutbound.URLTestOptions.Outbounds = append(groupOutbound.URLTestOptions.Outbounds, groupOutbounds...) + } + outbounds[groupIndex] = groupOutbound + return outbounds +} diff --git a/template/render_route.go b/template/render_route.go new file mode 100644 index 0000000..7cbf3d7 --- /dev/null +++ b/template/render_route.go @@ -0,0 +1,177 @@ +package template + +import ( + M "github.com/sagernet/serenity/common/metadata" + "github.com/sagernet/serenity/common/semver" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/option" + N "github.com/sagernet/sing/common/network" +) + +func (t *Template) renderRoute(metadata M.Metadata, options *option.Options) error { + if options.Route == nil { + options.Route = &option.RouteOptions{ + GeoIP: t.CustomGeoIP, + Geosite: t.CustomGeosite, + RuleSet: t.CustomRuleSet, + } + } + if !t.DisableTrafficBypass { + t.renderGeoResources(metadata, options) + } + disable18Features := metadata.Version == nil || metadata.Version.LessThan(semver.ParseVersion("1.8.0-alpha.10")) + options.Route.Rules = []option.Rule{ + { + Type: C.RuleTypeLogical, + LogicalOptions: option.LogicalRule{ + Mode: C.LogicalTypeOr, + Rules: []option.Rule{ + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultRule{ + Network: []string{N.NetworkUDP}, + Port: []uint16{53}, + }, + }, + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultRule{ + Protocol: []string{C.ProtocolDNS}, + }, + }, + }, + Outbound: DNSTag, + }, + }, + } + if !t.DisableTrafficBypass && !t.DisableDefaultRules { + options.Route.Rules = append(options.Route.Rules, option.Rule{ + Type: C.RuleTypeLogical, + LogicalOptions: option.LogicalRule{ + Mode: C.LogicalTypeOr, + Rules: []option.Rule{ + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultRule{ + Network: []string{N.NetworkUDP}, + Port: []uint16{443}, + }, + }, + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultRule{ + Protocol: []string{C.ProtocolSTUN}, + }, + }, + }, + Outbound: BlockTag, + }, + }) + } + directTag := t.DirectTag + defaultTag := t.DefaultTag + if directTag == "" { + directTag = DefaultDirectTag + } + if defaultTag == "" { + defaultTag = DefaultDefaultTag + } + if disable18Features { + options.Route.Rules = append(options.Route.Rules, option.Rule{ + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultRule{ + GeoIP: []string{"private"}, + Outbound: directTag, + }, + }) + } else { + options.Route.Rules = append(options.Route.Rules, option.Rule{ + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultRule{ + IPIsPrivate: true, + Outbound: directTag, + }, + }) + } + if !t.DisableClashMode { + modeGlobal := t.ClashModeGlobal + modeDirect := t.ClashModeDirect + if modeGlobal == "" { + modeGlobal = "Global" + } + if modeDirect == "" { + modeDirect = "Direct" + } + options.Route.Rules = append(options.Route.Rules, option.Rule{ + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultRule{ + ClashMode: modeGlobal, + Outbound: defaultTag, + }, + }, option.Rule{ + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultRule{ + ClashMode: modeDirect, + Outbound: directTag, + }, + }) + } + options.Route.Rules = append(options.Route.Rules, t.PreRules...) + if len(t.CustomRules) == 0 { + if !t.DisableTrafficBypass { + if t.DisableRuleSet || disable18Features { + options.Route.Rules = append(options.Route.Rules, option.Rule{ + Type: C.RuleTypeLogical, + LogicalOptions: option.LogicalRule{ + Mode: C.LogicalTypeAnd, + Rules: []option.Rule{ + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultRule{ + Geosite: []string{"geolocation-!cn"}, + Invert: true, + }, + }, + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultRule{ + GeoIP: []string{"cn"}, + Geosite: []string{"cn", "category-companies@cn"}, + Domain: []string{"download.jetbrains.com"}, + }, + }, + }, + Outbound: directTag, + }, + }) + } else { + options.Route.Rules = append(options.Route.Rules, option.Rule{ + Type: C.RuleTypeLogical, + LogicalOptions: option.LogicalRule{ + Mode: C.LogicalTypeAnd, + Rules: []option.Rule{ + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultRule{ + RuleSet: []string{"geosite-geolocation-!cn"}, + Invert: true, + }, + }, + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultRule{ + RuleSet: []string{"geoip-cn", "geosite-cn", "geosite-category-companies@cn"}, + Domain: []string{"download.jetbrains.com"}, + }, + }, + }, + Outbound: "direct", + }, + }) + } + } + } else { + options.Route.Rules = append(options.Route.Rules, t.CustomRules...) + } + return nil +} diff --git a/template/template.go b/template/template.go new file mode 100644 index 0000000..8ed7402 --- /dev/null +++ b/template/template.go @@ -0,0 +1,66 @@ +package template + +import ( + "regexp" + + M "github.com/sagernet/serenity/common/metadata" + "github.com/sagernet/serenity/option" + "github.com/sagernet/serenity/subscription" + "github.com/sagernet/serenity/template/filter" + boxOption "github.com/sagernet/sing-box/option" + E "github.com/sagernet/sing/common/exceptions" +) + +const ( + DefaultMixedPort = 8080 + DNSDefaultTag = "default" + DNSLocalTag = "local" + DNSLocalSetupTag = "local_setup" + DNSFakeIPTag = "remote" + DefaultDNS = "tls://8.8.8.8" + DefaultDNSLocal = "114.114.114.114" + DefaultDefaultTag = "Default" + DefaultDirectTag = "direct" + BlockTag = "block" + DNSTag = "dns" + DefaultURLTestTag = "URLTest" +) + +var Default = new(Template) + +type Template struct { + option.Template + groups []*ExtraGroup +} + +type ExtraGroup struct { + option.ExtraGroup + filter []*regexp.Regexp + exclude []*regexp.Regexp +} + +func (t *Template) Render(metadata M.Metadata, profileName string, outbounds [][]boxOption.Outbound, subscriptions []*subscription.Subscription) (*boxOption.Options, error) { + var options boxOption.Options + err := t.renderDNS(metadata, &options) + if err != nil { + return nil, E.Cause(err, "render dns") + } + err = t.renderInbounds(metadata, &options) + if err != nil { + return nil, E.Cause(err, "render inbounds") + } + err = t.renderOutbounds(metadata, &options, outbounds, subscriptions) + if err != nil { + return nil, E.Cause(err, "render outbounds") + } + err = t.renderRoute(metadata, &options) + if err != nil { + return nil, E.Cause(err, "render route") + } + err = t.renderExperimental(metadata, &options, profileName) + if err != nil { + return nil, E.Cause(err, "render experimental") + } + filter.Filter(metadata, &options) + return &options, nil +}