commit 1e27e80af9b10c99a293260065c52e65b03957f2 Author: 世界 Date: Tue Dec 19 20:00:00 2023 +0800 Init commit 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 +}