├── .github ├── FUNDING.yml ├── dependabot.yml └── workflows │ ├── ghcr.yml │ ├── go.yml │ └── release.yml ├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── cidr.csv ├── cmd └── sniproxy │ ├── config.defaults.yaml │ └── main.go ├── domains.csv ├── go.mod ├── go.sum ├── install.sh └── pkg ├── acl ├── acl.go ├── acl_test.go ├── cidr.go ├── domain.go ├── geoip.go └── override.go ├── conf.go ├── dns.go ├── dns_test.go ├── doc.go ├── doh ├── certtools.go ├── config.go ├── google.go ├── ietf.go ├── main.go ├── parse_test.go ├── server.go └── version.go ├── httpproxy.go ├── https.go ├── https_sni.go └── publicip.go /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: mosajjal 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 13 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 14 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "github-actions" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "weekly" 12 | -------------------------------------------------------------------------------- /.github/workflows/ghcr.yml: -------------------------------------------------------------------------------- 1 | name: Publish Container Image to Github Container Registry 2 | on: 3 | release: 4 | types: [published] 5 | workflow_dispatch: 6 | 7 | env: 8 | REGISTRY: "ghcr.io" 9 | IMAGE_NAME: ${{ github.repository }} 10 | 11 | jobs: 12 | build-and-push-image: 13 | runs-on: ubuntu-latest 14 | permissions: 15 | contents: read 16 | packages: write 17 | 18 | steps: 19 | - name: Checkout repository 20 | uses: actions/checkout@v4 21 | 22 | - name: Log in to the Container registry 23 | uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 24 | with: 25 | registry: ${{ env.REGISTRY }} 26 | username: ${{ github.actor }} 27 | password: ${{ secrets.GITHUB_TOKEN }} 28 | 29 | - name: Set up QEMU 30 | uses: docker/setup-qemu-action@v3 31 | - name: Set up Docker Buildx 32 | uses: docker/setup-buildx-action@v3 33 | 34 | - name: Extract metadata (tags, labels) for Docker 35 | id: meta 36 | uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 37 | with: 38 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 39 | 40 | - name: Build and push Docker image 41 | uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 42 | with: 43 | context: . 44 | push: true 45 | tags: ${{ steps.meta.outputs.tags }} 46 | labels: ${{ steps.meta.outputs.labels }} 47 | platforms: linux/amd64,linux/arm/v6,linux/arm64 48 | -------------------------------------------------------------------------------- /.github/workflows/go.yml: -------------------------------------------------------------------------------- 1 | name: Go 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | pull_request: 7 | branches: [master] 8 | 9 | jobs: 10 | build: 11 | name: Build 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Check out code into the Go module directory 15 | uses: actions/checkout@v4 16 | 17 | - name: Set up Go 3.x 18 | uses: actions/setup-go@v5 19 | with: 20 | go-version: "stable" 21 | 22 | - name: Get dependencies 23 | run: go get -v -t -d ./... 24 | 25 | - name: Build 26 | run: go build -v ./... 27 | 28 | - name: Test 29 | run: go test -v ./... 30 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Publish binaries on Release 2 | on: 3 | release: 4 | types: [created] 5 | 6 | jobs: 7 | releases-matrix: 8 | name: Release Go Binary 9 | runs-on: ubuntu-latest 10 | strategy: 11 | matrix: 12 | # build and publish in parallel: linux/386, linux/amd64, windows/386, windows/amd64, darwin/386, darwin/amd64 13 | goos: [linux, windows, darwin, freebsd, openbsd] 14 | goarch: ["386", amd64, arm64, arm] 15 | exclude: 16 | - goarch: "386" 17 | goos: darwin 18 | - goarch: "arm" 19 | goos: darwin 20 | steps: 21 | - uses: actions/checkout@v4 22 | - uses: wangyoucao577/go-release-action@master 23 | env: 24 | CGO_ENABLED: 0 # support alpine 25 | with: 26 | github_token: ${{ secrets.GITHUB_TOKEN }} 27 | goos: ${{ matrix.goos }} 28 | goarch: ${{ matrix.goarch }} 29 | project_path: "./cmd/sniproxy" 30 | ldflags: "-s -w -X main.version=${{ github.event.release.tag_name }} -X main.commit=${{ github.sha }}" 31 | build_flags: -v 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled Object files, Static and Dynamic libs (Shared Objects) 2 | *.o 3 | *.a 4 | *.so 5 | 6 | # Folders 7 | _obj 8 | _test 9 | 10 | # Architecture specific extensions/prefixes 11 | *.[568vq] 12 | [568vq].out 13 | 14 | *.cgo1.go 15 | *.cgo2.c 16 | _cgo_defun.c 17 | _cgo_gotypes.go 18 | _cgo_export.* 19 | 20 | _testmain.go 21 | 22 | *.exe 23 | *.test 24 | *.prof 25 | config.yaml 26 | 27 | .TODO.md 28 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM --platform=${BUILDPLATFORM:-linux/amd64} golang:alpine 2 | LABEL maintainer="Ali Mosajjal " 3 | 4 | ARG TARGETPLATFORM 5 | ARG BUILDPLATFORM 6 | ARG TARGETOS 7 | ARG TARGETARCH 8 | 9 | RUN apk add --no-cache git 10 | RUN mkdir /app 11 | ADD . /app/ 12 | WORKDIR /app/cmd/sniproxy 13 | ENV CGO_ENABLED=0 14 | RUN GOOS=${TARGETOS} GOARCH=${TARGETARCH} GOFLAGS=-buildvcs=false go build -ldflags "-s -w -X main.version=$(git describe --tags) -X main.commit=$(git rev-parse HEAD)" -o sniproxy . 15 | CMD ["/app/cmd/sniproxy/sniproxy"] 16 | 17 | FROM scratch 18 | COPY --from=0 /app/cmd/sniproxy/sniproxy /sniproxy 19 | ENTRYPOINT ["/sniproxy"] 20 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2025 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | 7 | * Redistributions of source code must retain the above copyright notice, this 8 | list of conditions and the following disclaimer. 9 | 10 | * Redistributions in binary form must reproduce the above copyright notice, 11 | this list of conditions and the following disclaimer in the documentation 12 | and/or other materials provided with the distribution. 13 | 14 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 15 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 16 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 17 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 18 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 19 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 20 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 21 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 22 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 23 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 24 | 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # sniproxy 2 | Continuation of [byosh] and [SimpleSNIProxy] projects. 3 | 4 | see the docs [here](https://pkg.go.dev/github.com/mosajjal/sniproxy/v2) -------------------------------------------------------------------------------- /cidr.csv: -------------------------------------------------------------------------------- 1 | 77.77.0.0/16,allow 2 | 0.0.0.0/0,reject -------------------------------------------------------------------------------- /cmd/sniproxy/config.defaults.yaml: -------------------------------------------------------------------------------- 1 | # you can use environment variables to override the settings provided int he default config or the config file as well 2 | # to do that use the `SNIPROXY_` prefix, followed by the full tree of the parameter you need to update, separated by two underscores (__) 3 | # for example, if you want the dns server to be bounded to 0.0.0.0:5555 rather than the default 0.0.0.0:53, you run the sniproxy (or the container) 4 | # with the following environment variable 5 | # SNIPROXY_GENERAL__BIND_DNS_OVER_UDP=0.0.0.0:5555 6 | # note that there are 2 underscores between general and BIND_DNS_OVER_UDP 7 | general: 8 | # Upsteam DNS URI. examples: Upstream DNS URI. examples: udp://1.1.1.1:53, tcp://1.1.1.1:53, tcp-tls://1.1.1.1:853, https://dns.google/dns-query 9 | # NOTE: if you're using SOCKS, avoid using UDP for upstream DNS 10 | upstream_dns: udp://8.8.8.8:53 11 | # enable send DNS through socks5 12 | upstream_dns_over_socks5: false 13 | # Use a SOCKS proxy for upstream HTTP/HTTPS traffic. Example: socks5://admin: 14 | upstream_socks5: 15 | # DNS Port to listen on. Should remain 53 in most cases. MUST NOT be empty 16 | bind_dns_over_udp: "0.0.0.0:53" 17 | # enable DNS over TCP. empty disables it. example: "127.0.0.1:53" 18 | bind_dns_over_tcp: 19 | # enable DNS over TLS. empty disables it. example: "127.0.0.1:853" 20 | bind_dns_over_tls: 21 | # enable DNS over QUIC. empty disables it. example: "127.0.0.1:8853" 22 | bind_dns_over_quic: 23 | # Path to the certificate for DoH, DoT and DoQ. eg: /tmp/mycert.pem 24 | tls_cert: 25 | # Path to the certificate key for DoH, DoT and DoQ. eg: /tmp/mycert.key 26 | tls_key: 27 | # HTTP Port to listen on. Should remain 80 in most cases. use :80 to listen on both IPv4 and IPv6 28 | bind_http: "0.0.0.0:80" 29 | # bind additional ports for HTTP. a list of portsor ranges separated by commas. example: "8080,8081-8083". follows the same listen address as bind_http 30 | bind_http_additional: 31 | # HTTPS Port to listen on. Should remain 443 in most cases 32 | bind_https: "0.0.0.0:443" 33 | # bind additional ports for HTTPS. a list of portsor ranges separated by commas. example: "8443,8444-8446". follows the same listen address as bind_https 34 | bind_https_additional: 35 | # Enable prometheus endpoint on IP:PORT. example: 127.0.0.1:8080. Always exposes /metrics and only supports HTTP 36 | bind_prometheus: 37 | # Interface used for outbound TLS connections. uses OS prefered one if empty 38 | interface: 39 | # Preferred ip version for outgoing connections. choises: ipv4 (or 4), ipv6 (or 6), ipv4only, ipv6only, any. empty (or 0) means any. 40 | # numeric values are kept for backward compatibility 41 | preferred_version: 42 | # Public IPv4 of the server, reply address of DNS A queries 43 | public_ipv4: 44 | # Public IPv6 of the server, reply address of DNS AAAA queries 45 | public_ipv6: 46 | # allow connections from sniproxy to RFC1918 addresses. default is false. 47 | allow_conn_to_local: false 48 | # log level for the application. choices: debug, info, warn, error 49 | # by default, the logs are colored so they are not suited for logging to a file. 50 | # in order to disable colors, set NO_COLOR=true in the environment variables 51 | log_level: info 52 | 53 | acl: 54 | # geoip filtering 55 | # 56 | # the logic is as follows: 57 | # 1. if mmdb is not loaded or not available, it's fail-open (allow by default) 58 | # 2. if the IP can't be resolved to a country, it's rejected 59 | # 3. if the country is in the blocked list, it's rejected 60 | # 4. if the country is in the allowed list, it's allowed 61 | # note that the reject list is checked first and takes priority over the allow list 62 | # if the IP's country doesn't match any of the above, it's allowed if the blocked list is not empty 63 | # for example, if the blockedlist is [US] and the allowedlist is empty, a connection from 64 | # CA will be allowed. but if blockedlist is empty and allowedlist is [US], a connection from 65 | # CA will be rejected. 66 | geoip: 67 | enabled: false 68 | # priority of the geoip filter. lower priority means it's checked first, meaning it can be ovveriden by other ACLs with higehr priority number. 69 | priority: 10 70 | # strictly blocked countries 71 | blocked: 72 | # allowed countries 73 | allowed: 74 | # Path to the MMDB file. eg: /tmp/Country.mmdb, https://raw.githubusercontent.com/Loyalsoldier/geoip/release/Country.mmdb 75 | path: 76 | # Interval to re-fetch the MMDB file 77 | refresh_interval: 24h0m0s 78 | # domain filtering 79 | domain: 80 | enabled: false # false means ALL domains will be allowed to go through the proxy 81 | # priority of the domain filter. lower priority means it's checked first. if multiple filters have the same priority, they're checked in random order 82 | priority: 20 83 | # Path to the domain list. eg: /tmp/domainlist.csv. Look at the example file for the format. 84 | path: 85 | # Interval to re-fetch the domain list 86 | refresh_interval: 1h0m0s 87 | # IP/CIDR filtering 88 | cidr: 89 | enabled: false 90 | # priority of the cidr filter. lower priority means it's checked first. if multiple filters have the same priority, they're checked in random order 91 | priority: 30 92 | # Path to the CIDR list. eg: /tmp/cidr.csv. Look at the example file for the format. 93 | path: 94 | # Interval to re-fetch the domain list 95 | refresh_interval: 1h0m0s 96 | # FQDN override. This ACL is used to override the destination IP to not be the one resolved by the upstream DNS or the proxy itself, rather a custom IP and port 97 | # if the destination is HTTP, it uses tls_cert and tls_key certificate to terminate the original connection. 98 | override: 99 | enabled: false 100 | # priority of the override filter. lower priority means it's checked first. if multiple filters have the same priority, they're checked in random order 101 | priority: 40 102 | # override rules. unlike others, this one does not require a path to a file. it's a map of FQDNs wildcards to IPs and ports. only HTTPS is supported 103 | # currently, these rules are checked with a simple for loop and string matching, 104 | # so it's not suited for a large number of rules. if you have a big list of rules 105 | # use a reverse proxy in front of sniproxy rather than using sniproxy as a reverse proxy 106 | rules: 107 | "one.one.one.one": "1.1.1.1:443" 108 | "google.com": "8.8.8.8:443" 109 | # enable listening on DoH on a specific SNI. example: "myawesomedoh.example.com". empty disables it. If you need DoH to be enabled and don't want 110 | # any other overrides, enable this ACL with empty rules. DoH SNI will add a default rule and start. 111 | doh_sni: "myawesomedoh.example.com" 112 | # Path to the certificate for handling tls decryption. eg: /tmp/mycert.pem 113 | tls_cert: 114 | # Path to the certificate key handling tls decryption. eg: /tmp/mycert.key 115 | tls_key: 116 | -------------------------------------------------------------------------------- /cmd/sniproxy/main.go: -------------------------------------------------------------------------------- 1 | // sniproxy's main CLI entrpoint. 2 | package main 3 | 4 | import ( 5 | "fmt" 6 | "net" 7 | "net/http" 8 | "net/netip" 9 | "os" 10 | "path/filepath" 11 | "strings" 12 | "time" 13 | 14 | prometheusmetrics "github.com/deathowl/go-metrics-prometheus" 15 | "github.com/google/uuid" 16 | "github.com/knadh/koanf" 17 | "github.com/knadh/koanf/parsers/yaml" 18 | "github.com/knadh/koanf/providers/env" 19 | "github.com/knadh/koanf/providers/file" 20 | "github.com/knadh/koanf/providers/rawbytes" 21 | "github.com/pkg/profile" 22 | "github.com/prometheus/client_golang/prometheus" 23 | "github.com/rcrowley/go-metrics" 24 | "github.com/rs/zerolog" 25 | 26 | "github.com/prometheus/client_golang/prometheus/promhttp" 27 | "github.com/spf13/cobra" 28 | 29 | _ "embed" 30 | stdlog "log" 31 | 32 | sniproxy "github.com/mosajjal/sniproxy/v2/pkg" 33 | "github.com/mosajjal/sniproxy/v2/pkg/acl" 34 | "github.com/mosajjal/sniproxy/v2/pkg/doh" 35 | ) 36 | 37 | var c sniproxy.Config 38 | 39 | var ( 40 | version = "v2-UNKNOWN" 41 | commit = "NOT PROVIDED" 42 | envPrefix = "SNIPROXY_" // used as the prefix to read env variables at runtime 43 | ) 44 | 45 | //go:embed config.defaults.yaml 46 | var defaultConfig []byte 47 | 48 | // disable colors in logging if NO_COLOR is set 49 | var nocolorLog = strings.ToLower(os.Getenv("NO_COLOR")) == "true" 50 | var logger = zerolog.New(os.Stderr).With().Timestamp().Logger().Output(zerolog.ConsoleWriter{Out: os.Stderr, TimeFormat: time.RFC3339, NoColor: nocolorLog}) 51 | 52 | func enableProfile(profileType string) interface{ Stop() } { 53 | switch profileType { 54 | case "": 55 | return nil 56 | case "cpu": 57 | return profile.Start(profile.CPUProfile) 58 | case "mem": 59 | return profile.Start(profile.MemProfile) 60 | case "block": 61 | return profile.Start(profile.BlockProfile) 62 | case "mutex": 63 | return profile.Start(profile.MutexProfile) 64 | case "trace": 65 | return profile.Start(profile.TraceProfile) 66 | case "threadcreate": 67 | return profile.Start(profile.ThreadcreationProfile) 68 | case "goroutine": 69 | return profile.Start(profile.GoroutineProfile) 70 | case "clock": 71 | return profile.Start(profile.ClockProfile) 72 | default: 73 | logger.Error().Msgf("unknown profile type: %s", profileType) 74 | } 75 | return nil 76 | } 77 | 78 | func main() { 79 | 80 | cmd := &cobra.Command{ 81 | Use: "sniproxy", 82 | Short: "SNI Proxy with Embedded DNS Server", 83 | Run: func(_ *cobra.Command, _ []string) {}, 84 | } 85 | flags := cmd.Flags() 86 | config := flags.StringP("config", "c", "", "path to YAML configuration file") 87 | _ = flags.Bool("defaultconfig", false, "write the default config yaml file to stdout") 88 | prof := flags.String("prof", "", "enable profiling. can be one of: [cpu, mem, block, mutex, trace, threadcreate, goroutine, clock]") 89 | _ = flags.BoolP("version", "v", false, "show version info and exit") 90 | if err := cmd.Execute(); err != nil { 91 | logger.Error().Msgf("failed to execute command: %s", err) 92 | return 93 | } 94 | if flags.Changed("help") { 95 | return 96 | } 97 | if flags.Changed("version") { 98 | fmt.Printf("sniproxy version %s, commit %s\n", version, commit) 99 | return 100 | } 101 | if flags.Changed("defaultconfig") { 102 | fmt.Fprint(os.Stdout, string(defaultConfig)) 103 | return 104 | } 105 | ret := enableProfile(*prof) 106 | if ret != nil { 107 | defer ret.Stop() 108 | } 109 | 110 | k := koanf.New(".") 111 | // load the defaults 112 | if err := k.Load(rawbytes.Provider(defaultConfig), yaml.Parser()); err != nil { 113 | panic(err) 114 | } 115 | if *config != "" { 116 | if err := k.Load(file.Provider(*config), yaml.Parser()); err != nil { 117 | panic(err) 118 | } 119 | } 120 | // load environment variables starting with envPrefix 121 | k.Load(env.Provider(envPrefix, ".", func(s string) string { 122 | return strings.Replace(strings.ToLower( 123 | strings.TrimPrefix(s, envPrefix)), "__", ".", -1) 124 | }), nil) 125 | 126 | logger.Info().Msgf("starting sniproxy. version %s, commit %s", version, commit) 127 | 128 | // verify and load config 129 | generalConfig := k.Cut("general") 130 | 131 | stdlog.SetFlags(0) 132 | stdlog.SetOutput(logger) 133 | 134 | switch l := generalConfig.String("log_level"); l { 135 | case "debug": 136 | zerolog.SetGlobalLevel(zerolog.DebugLevel) 137 | logger = logger.With().Caller().Logger() 138 | case "info": 139 | zerolog.SetGlobalLevel(zerolog.InfoLevel) 140 | logger = logger.With().Caller().Logger() 141 | case "warn": 142 | zerolog.SetGlobalLevel(zerolog.WarnLevel) 143 | case "error": 144 | zerolog.SetGlobalLevel(zerolog.ErrorLevel) 145 | default: 146 | zerolog.SetGlobalLevel(zerolog.InfoLevel) 147 | } 148 | 149 | c.UpstreamDNS = generalConfig.String("upstream_dns") 150 | c.UpstreamDNSOverSocks5 = generalConfig.Bool("upstream_dns_over_socks5") 151 | c.UpstreamSOCKS5 = generalConfig.String("upstream_socks5") 152 | c.BindDNSOverUDP = generalConfig.String("bind_dns_over_udp") 153 | c.BindDNSOverTCP = generalConfig.String("bind_dns_over_tcp") 154 | c.BindDNSOverTLS = generalConfig.String("bind_dns_over_tls") 155 | c.BindDNSOverQuic = generalConfig.String("bind_dns_over_quic") 156 | c.TLSCert = generalConfig.String("tls_cert") 157 | c.TLSKey = generalConfig.String("tls_key") 158 | c.BindHTTP = generalConfig.String("bind_http") 159 | c.BindHTTPAdditional = generalConfig.Strings("bind_http_additional") 160 | c.BindHTTPS = generalConfig.String("bind_https") 161 | c.BindHTTPSAdditional = generalConfig.Strings("bind_https_additional") 162 | c.Interface = generalConfig.String("interface") 163 | c.PreferredVersion = generalConfig.String("preferred_version") 164 | 165 | // if preferred version is ipv6only, we don't need to check for ipv4 public ip 166 | if c.PreferredVersion != "ipv6only" { 167 | c.PublicIPv4 = generalConfig.String("public_ipv4") 168 | if c.PublicIPv4 == "" { 169 | var err error 170 | c.PublicIPv4, err = sniproxy.GetPublicIPv4() 171 | if err != nil { 172 | logger.Fatal().Msgf("failed to get public IPv4, while ipv4 is enabled in preferred_version: %s", err) 173 | } 174 | logger.Info().Msgf("public IPv4 (automatically determined): %s", c.PublicIPv4) 175 | } else { 176 | logger.Info().Msgf("public IPv4 (manually provided): %s", c.PublicIPv4) 177 | } 178 | } 179 | // if preferred version is ipv4only, we don't need to check for ipv6 public ip 180 | if c.PreferredVersion != "ipv4only" { 181 | c.PublicIPv6 = generalConfig.String("public_ipv6") 182 | if c.PublicIPv6 == "" { 183 | var err error 184 | c.PublicIPv6, err = sniproxy.GetPublicIPv6() 185 | if err != nil { 186 | logger.Fatal().Msgf("failed to get public IPv6, while ipv6 is enabled in preferred_version: %s", err) 187 | } 188 | logger.Info().Msgf("public IPv6 (automatically determined): %s", c.PublicIPv6) 189 | } else { 190 | logger.Info().Msgf("public IPv6 (manually provided): %s", c.PublicIPv6) 191 | } 192 | } 193 | 194 | // in any case, at least one public IP address is needed to run the server. if both are empty, we can't proceed 195 | if c.PublicIPv4 == "" && c.PublicIPv6 == "" { 196 | logger.Error().Msg("Could not automatically determine any public IP. you should provide it manually using --publicIPv4 or --publicIPv6 or both.") 197 | logger.Error().Msg("if your environment is single-stack, you can use --preferredVersion to specify the version as ipv4only or ipv6only.") 198 | return 199 | } 200 | 201 | c.BindPrometheus = generalConfig.String("prometheus") 202 | c.AllowConnToLocal = generalConfig.Bool("allow_conn_to_local") 203 | 204 | var err error 205 | c.ACL, err = acl.StartACLs(&logger, k) 206 | if err != nil { 207 | logger.Error().Msgf("failed to start ACLs: %s", err) 208 | return 209 | } 210 | 211 | // set up metrics 212 | // TODO: add ipv6 vs ipv4 metrics 213 | c.RecievedDNS = metrics.GetOrRegisterCounter("dns.requests.recieved", metrics.DefaultRegistry) 214 | c.ProxiedDNS = metrics.GetOrRegisterCounter("dns.requests.proxied", metrics.DefaultRegistry) 215 | c.RecievedHTTP = metrics.GetOrRegisterCounter("http.requests.recieved", metrics.DefaultRegistry) 216 | c.ProxiedHTTP = metrics.GetOrRegisterCounter("http.requests.proxied", metrics.DefaultRegistry) 217 | c.RecievedHTTPS = metrics.GetOrRegisterCounter("https.requests.recieved", metrics.DefaultRegistry) 218 | c.ProxiedHTTPS = metrics.GetOrRegisterCounter("https.requests.proxied", metrics.DefaultRegistry) 219 | 220 | if c.BindPrometheus != "" { 221 | p := prometheusmetrics.NewPrometheusProvider(metrics.DefaultRegistry, "sniproxy", c.PublicIPv4, prometheus.DefaultRegisterer, 1*time.Second) 222 | go p.UpdatePrometheusMetrics() 223 | go func() { 224 | http.Handle("/metrics", promhttp.Handler()) 225 | logger.Info().Str( 226 | "address", c.BindPrometheus, 227 | ).Msg("starting metrics server") 228 | if err := http.ListenAndServe(c.BindPrometheus, promhttp.Handler()); err != nil { 229 | logger.Error().Msgf("%s", err) 230 | } 231 | }() 232 | } 233 | 234 | // generate self-signed certificate if not provided. 235 | if c.TLSCert == "" && c.TLSKey == "" { 236 | // generate a random 16 char string as hostname 237 | hostname := uuid.NewString()[:16] 238 | logger.Info().Msg("certificate was not provided, generating a self signed cert in temp directory") 239 | _, _, err := doh.GenerateSelfSignedCertKey(hostname, nil, nil, os.TempDir()) 240 | if err != nil { 241 | logger.Error().Msgf("error while generating self-signed cert: %s", err) 242 | } 243 | c.TLSCert = filepath.Join(os.TempDir(), hostname+".crt") 244 | c.TLSKey = filepath.Join(os.TempDir(), hostname+".key") 245 | } 246 | 247 | // if the "interface" configuration is provided, sniproxy must translate the interface name to the IP addresses 248 | // and add them to the source address list 249 | if c.Interface != "" { 250 | logger.Info().Msgf("Using interface %s", c.Interface) 251 | ief, err := net.InterfaceByName(c.Interface) 252 | if err != nil { 253 | logger.Error().Msg(err.Error()) 254 | } 255 | addrs, err := ief.Addrs() 256 | if err != nil { 257 | logger.Error().Msg(err.Error()) 258 | } 259 | // TODO: split ipv4 and ipv6 to different lists 260 | for _, addr := range addrs { 261 | c.SourceAddr = append(c.SourceAddr, netip.MustParseAddr(addr.String())) 262 | } 263 | } 264 | 265 | // set up dialer based on SOCKS5 configuration 266 | if err := c.SetDialer(logger); err != nil { 267 | logger.Error().Msgf("error setting up dialer: %v", err) 268 | return 269 | } 270 | 271 | // set up the DNS Client based on the configuration 272 | if err := c.SetDNSClient(logger); err != nil { 273 | logger.Error().Msgf("error setting up DNS client: %v", err) 274 | return 275 | } 276 | 277 | // get a list of http and https binds 278 | if err := c.SetBindHTTPListeners(logger); err != nil { 279 | logger.Error().Msgf("error setting up HTTP listeners: %v", err) 280 | return 281 | } 282 | logger.Info().Msgf("HTTP listeners: %v", c.BindHTTPListeners) 283 | if err := c.SetBindHTTPSListeners(logger); err != nil { 284 | logger.Error().Msgf("error setting up HTTPS listeners: %v", err) 285 | return 286 | } 287 | logger.Info().Msgf("HTTPS listeners: %v", c.BindHTTPSListeners) 288 | 289 | for _, addr := range c.BindHTTPListeners { 290 | go sniproxy.RunHTTP(&c, addr, logger) 291 | } 292 | for _, addr := range c.BindHTTPSListeners { 293 | go sniproxy.RunHTTPS(&c, addr, logger) 294 | } 295 | go sniproxy.RunDNS(&c, logger) 296 | 297 | // wait forever. 298 | // TODO: add signal handling here 299 | select {} 300 | } 301 | -------------------------------------------------------------------------------- /domains.csv: -------------------------------------------------------------------------------- 1 | djangoproject.com.,suffix 2 | fodev.org.,suffix 3 | android.com.,suffix 4 | amp.dev.,suffix 5 | prezi.com.,suffix 6 | mymavenrepo.com.,suffix 7 | siteground.com.,suffix 8 | machinelearningmastery.com.,suffix 9 | hex.pm.,suffix 10 | bazel.build.,suffix 11 | teamtreehouse.com.,suffix 12 | xero.com.,suffix 13 | microsoft.com.,suffix 14 | uxcam.com.,suffix 15 | xbox.com.,suffix 16 | gamepass.com.,suffix 17 | windows.com.,suffix 18 | microsoft.com.,suffix 19 | microsoft.com.,suffix 20 | xboxab.com.,suffix 21 | xboxab.net.,suffix 22 | xboxlive.com.,suffix 23 | xboxservices.com.,suffix 24 | mvnrepository.com.,suffix 25 | myget.org.,suffix 26 | pypi.org.,suffix 27 | chatgptonline.net.,suffix 28 | awsstatic.com.,suffix 29 | vultr.com.,suffix 30 | hetzner.cloud.,suffix 31 | hetzner.company.,suffix 32 | hetzner.com.,suffix 33 | hetzner.de.,suffix 34 | linode.com.,suffix 35 | cloudflare.com.,suffix 36 | aapanel.com.,suffix 37 | machinelearningmastery.com.,suffix 38 | miro.com.,suffix 39 | mintegral.com.,suffix 40 | comsol.com.,suffix 41 | deepmind.com.,suffix 42 | grammarly.com.,suffix 43 | helm.sh.,suffix 44 | rust-lang.org.,suffix 45 | redis.com.,suffix 46 | codegrepper.com.,suffix 47 | samacsys.com.,suffix 48 | dlang.org.,suffix 49 | yandex.com.,suffix 50 | my-netdata.io.,suffix 51 | bitvision.app.,suffix 52 | netdata.cloud.,suffix 53 | upwork.com.,suffix 54 | anchor.fm.,suffix 55 | bazel.build.,suffix 56 | grow.google.,suffix 57 | wallpaperget.com.,suffix 58 | time.google.com.,suffix 59 | postman.com.,suffix 60 | withgoogle.com.,suffix 61 | microblink.com.,suffix 62 | bluemix.net.,suffix 63 | huggingface.co.,suffix 64 | services.google.com.,suffix 65 | slidesgo.com.,suffix 66 | azure.com.,suffix 67 | hotjar.com.,suffix 68 | convertio.me.,suffix 69 | convertio.co.,suffix 70 | moz.com.,suffix 71 | ipinfo.io.,suffix 72 | openreview.net.,suffix 73 | ieee.com.,suffix 74 | ieee.org.,suffix 75 | pytorchlightning.ai.,suffix 76 | virtualmin.com.,suffix 77 | webmin.com.,suffix 78 | ifconfig.co.,suffix 79 | rapidapi.com.,suffix 80 | ipwhois.app.,suffix 81 | earthengine.google.com.,suffix 82 | wandb.ai.,suffix 83 | sitefinity.com.,suffix 84 | tomtom.com.,suffix 85 | exceedlms.com.,suffix 86 | bit.ly.,suffix 87 | mi.com.,suffix 88 | cnet.com.,suffix 89 | amazon.com.,suffix 90 | aclanthology.org.,suffix 91 | tinyurl.com.,suffix 92 | is.gd.,suffix 93 | programiz.com.,suffix 94 | terrytao.wordpress.com.,suffix 95 | sites.google.com.,suffix 96 | cppstories.com.,suffix 97 | gurobi.com.,suffix 98 | pub.dev.,suffix 99 | gvt1.com.,suffix 100 | swift.org.,suffix 101 | fbsbx.com.,suffix 102 | github.io.,suffix 103 | pdf2go.com.,suffix 104 | netacad.com.,suffix 105 | netdevgroup.com.,suffix 106 | reality.ai.,suffix 107 | mozilla.net.,suffix 108 | websiteforstudents.com.,suffix 109 | withgoogle.com.,suffix 110 | kite.com.,suffix 111 | googletagservices.com.,suffix 112 | alexa.com.,suffix 113 | getliner.com.,suffix 114 | jetbrains.space.,suffix 115 | ngrok.com.,suffix 116 | ngrok.io.,suffix 117 | gradle-dn.com.,suffix 118 | overleaf.com.,suffix 119 | jenkins.org.,suffix 120 | rubygems.org.,suffix 121 | ruby-doc.org.,suffix 122 | k8s.io.,suffix 123 | kubernetes.io.,suffix 124 | gcr.io.,suffix 125 | sstatic.net.,suffix 126 | kaggleusercontent.com.,suffix 127 | debian.org.,suffix 128 | arcgis.com.,suffix 129 | gravityforms.com.,suffix 130 | igdb.com.,suffix 131 | mendeley.com.,suffix 132 | eslint.org.,suffix 133 | amazonaws.com.,suffix 134 | code.videolan.org.,suffix 135 | videolan.org.,suffix 136 | google-analytics.com.,suffix 137 | timedoctor.com.,suffix 138 | conan.io.,suffix 139 | oddrun.ir.,suffix 140 | proandroiddev.com.,suffix 141 | superuser.com.,suffix 142 | parsely.com.,suffix 143 | huawei.com.,suffix 144 | leech.com.,suffix 145 | lightstep.com.,suffix 146 | optimizely.com.,suffix 147 | branch.io.,suffix 148 | serverfault.com.,suffix 149 | stackexchange.com.,suffix 150 | stackoverflow.com.,suffix 151 | glitch.me.,suffix 152 | glitch.com.,suffix 153 | vuejs.org.,suffix 154 | reactjs.org.,suffix 155 | adservice.google.com.,suffix 156 | jhipster.tech.,suffix 157 | algolia.net.,suffix 158 | adobelogin.com.,suffix 159 | kaggle.io.,suffix 160 | adobe.com.,suffix 161 | zend.com.,suffix 162 | symfony.com.,suffix 163 | classroom.google.com.,suffix 164 | csb.app.,suffix 165 | flutter-io.cn.,suffix 166 | dartlang.org.,suffix 167 | flutter.dev.,suffix 168 | paypal.com.,suffix 169 | web.dev.,suffix 170 | c9.io.,suffix 171 | codecov.io.,suffix 172 | coursehero.com.,suffix 173 | flaticon.com.,suffix 174 | githubapp.com.,suffix 175 | expressjs.com.,suffix 176 | twilio.com.,suffix 177 | xip.io.,suffix 178 | nip.io.,suffix 179 | kinsta.com.,suffix 180 | codex.cs.yale.edu.,suffix 181 | edx.org.,suffix 182 | chaquo.com.,suffix 183 | php.net.,suffix 184 | freedesktop.org.,suffix 185 | mybridge.co.,suffix 186 | githubusercontent.com.,suffix 187 | play.google.com.,suffix 188 | algolia.com.,suffix 189 | algolianet.com.,suffix 190 | developer.google.com.,suffix 191 | photodune.net.,suffix 192 | videohive.net.,suffix 193 | balena.io.,suffix 194 | laravel.com.,suffix 195 | salesforce.com.,suffix 196 | expo.io.,suffix 197 | expo.dev.,suffix 198 | clients.google.com.,suffix 199 | telerik.com.,suffix 200 | audiojungle.net.,suffix 201 | 3docean.net.,suffix 202 | sparkjava.com.,suffix 203 | zeit.co.,suffix 204 | graphicriver.net.,suffix 205 | mit.edu.,suffix 206 | tinyjpg.com.,suffix 207 | goanimate.com.,suffix 208 | hackerrank.com.,suffix 209 | gitlab.com.,suffix 210 | gitpod.io.,suffix 211 | atlassian.com.,suffix 212 | spiceworks.com.,suffix 213 | bugsnag.com.,suffix 214 | sentry.io.,suffix 215 | sentry-cdn.com.,suffix 216 | clients6.google.com.,suffix 217 | incredibuild.com.,suffix 218 | khronos.org.,suffix 219 | epicgames.com.,suffix 220 | enterprisedb.com.,suffix 221 | packagist.org.,suffix 222 | jenkov.com.,suffix 223 | bintray.com.,suffix 224 | edgesuite.net.,suffix 225 | marketingplatform.google.com.,suffix 226 | gopkg.in.,suffix 227 | labix.org.,suffix 228 | withgoogle.com.,suffix 229 | accounts.google.com.,suffix 230 | coinbase.com.,suffix 231 | schema.org.,suffix 232 | invisionapp.com.,suffix 233 | bitbucket.org.,suffix 234 | softonic.com.,suffix 235 | developers.google.com.,suffix 236 | nativescript.org.,suffix 237 | kaggle.com.,suffix 238 | ads.google.com.,suffix 239 | domains.google.com.,suffix 240 | tensorflow.org.,suffix 241 | apple.com.,suffix 242 | aws.amazon.com.,suffix 243 | dl.google.com.,suffix 244 | rapid7.com.,suffix 245 | pscdn.co.,suffix 246 | appengine.google.com.,suffix 247 | baeldung.com.,suffix 248 | envato-static.com.,suffix 249 | newrelic.com.,suffix 250 | google.ai.,suffix 251 | gitlab.io.,suffix 252 | flutter.io.,suffix 253 | ai.google.,suffix 254 | doubleclickbygoogle.com.,suffix 255 | doubleclick.net.,suffix 256 | gstatic.com.,suffix 257 | jwplayer.com.,suffix 258 | caddyserver.com.,suffix 259 | caddy.community.,suffix 260 | googleadservices.com.,suffix 261 | googletagmanager.com.,suffix 262 | androidstudio.googleblog.com.,suffix 263 | geforce.com.,suffix 264 | socket.io.,suffix 265 | googleusercontent.com.,suffix 266 | en25.com.,suffix 267 | tinypng.com.,suffix 268 | fsdn.com.,suffix 269 | justpaste.it.,suffix 270 | demandbase.com.,suffix 271 | appspot.com.,suffix 272 | element14.com.,suffix 273 | unity3d.com.,suffix 274 | sourceforge.net.,suffix 275 | unity.com.,suffix 276 | myfonts.net.,suffix 277 | jaspersoft.com.,suffix 278 | design.google.com.,suffix 279 | stripe.com.,suffix 280 | python.org.,suffix 281 | pypi.org.,suffix 282 | gravatar.com.,suffix 283 | cloud.google.com.,suffix 284 | analytics.google.com.,suffix 285 | optimize.google.com.,suffix 286 | tagmanager.google.com.,suffix 287 | fiber.google.com.,suffix 288 | dl-ssl.google.com.,suffix 289 | dns.google.com.,suffix 290 | firebase.google.com.,suffix 291 | firebase.com.,suffix 292 | googleapis.com.,suffix 293 | jetbrains.com.,suffix 294 | seleniumhq.org.,suffix 295 | invis.io.,suffix 296 | i18next.com.,suffix 297 | java.com.,suffix 298 | vuforia.com.,suffix 299 | cocalc.com.,suffix 300 | gradle.org.,suffix 301 | fabric.io.,suffix 302 | apis.google.com.,suffix 303 | godoc.org.,suffix 304 | paypalobjects.com.,suffix 305 | count.ly.,suffix 306 | khanacademy.org.,suffix 307 | oracle.com.,suffix 308 | crashlytics.com.,suffix 309 | origin.com.,suffix 310 | explainshell.com.,suffix 311 | packtpub.com.,suffix 312 | traviscistatus.com.,suffix 313 | golang.org.,suffix 314 | storage.googleapis.com.,suffix 315 | flutterlearn.com.,suffix 316 | spring.io.,suffix 317 | themeforest.net.,suffix 318 | flurry.com.,suffix 319 | softlayer.com.,suffix 320 | mailgun.com.,suffix 321 | bootstrapcdn.com.,suffix 322 | download.virtualbox.org.,suffix 323 | sun.com.,suffix 324 | books.google.com.,suffix 325 | mysql.com.,suffix 326 | unrealengine.com.,suffix 327 | mongodb.org.,suffix 328 | mongodb.com.,suffix 329 | swaggerhub.com.,suffix 330 | envato.com.,suffix 331 | apps.admob.com.,suffix 332 | grafana.com.,suffix 333 | cp.maxcdn.com.,suffix 334 | codecanyon.net.,suffix 335 | compiles.overleaf.com.,suffix 336 | amd.com.,suffix 337 | payments.google.com.,suffix 338 | ibm.com.,suffix 339 | jenkins-ci.org.,suffix 340 | mbed.com.,suffix 341 | ti.com.,suffix 342 | netbeans.org.,suffix 343 | vmware.com.,suffix 344 | toggl.com.,suffix 345 | docker.com.,suffix 346 | docker.io.,suffix 347 | datacamp.com.,suffix 348 | googlesource.com.,suffix 349 | polymer-project.org.,suffix 350 | udemy.com.,suffix 351 | udemycdn.com.,suffix 352 | udemycdn-a.com.,suffix 353 | material.io.,suffix 354 | teamviewer.com.,suffix 355 | intel.com.,suffix 356 | developer.chrome.com.,suffix 357 | github.com.,suffix 358 | jfrog.org.,suffix 359 | sonatype.org.,suffix 360 | maven.org.,suffix 361 | jitpack.io.,suffix 362 | maven.google.com.,suffix 363 | cloudfront.net.,suffix 364 | nvidia.com.,suffix 365 | rstudio.com.,suffix 366 | sendgrid.com.,suffix 367 | kubernetes.io.,suffix 368 | issuetracker.google.com.,suffix 369 | virtualbox.org.,suffix 370 | atlassian.net.,suffix 371 | ubuntu.com.,suffix 372 | b4x.com.,suffix 373 | elastic.co.,suffix 374 | launchpad.net.,suffix 375 | medium.com.,suffix 376 | code.google.com.,suffix 377 | realm.io.,suffix 378 | maas.io.,suffix 379 | docs.datastax.com.,suffix 380 | splunk.com.,suffix 381 | gitlab-static.net.,suffix 382 | releases.hashicorp.com.,suffix 383 | livefyre.com.,suffix 384 | cloudera.com.,suffix 385 | apache.org.,suffix 386 | vagrantup.com.,suffix 387 | metasploit.com.,suffix 388 | coursera.org.,suffix 389 | nodejs.org.,suffix 390 | macromedia.com.,suffix 391 | akamaized.net.,suffix 392 | npmjs.org.,suffix 393 | dell.com.,suffix 394 | gallery.io.,suffix 395 | mathworks.com.,suffix 396 | lenovo.com.,suffix 397 | jitsi.org.,suffix 398 | anydesk.com.,suffix 399 | ant.design.,suffix 400 | centos.org.,suffix 401 | quay.io.,suffix 402 | bitvise.com.,suffix 403 | nextjs.org.,suffix 404 | ni.com.,suffix 405 | graphql.org.,suffix 406 | altera.com.,suffix 407 | microchip.com.,suffix 408 | codesandbox.io.,suffix 409 | spotify.com.,suffix 410 | scdn.co.,suffix 411 | godbolt.org.,suffix 412 | zoom.us.,suffix 413 | freecodecamp.org.,suffix 414 | libraries.io.,suffix 415 | githubassets.com.,suffix 416 | i.stack.imgur.com.,suffix 417 | bitsrc.io.,suffix 418 | hyper.is.,suffix 419 | serialport.io.,suffix 420 | curd.io.,suffix 421 | wikia.com.,suffix 422 | fluttercrashcourse.com.,suffix 423 | developer.samsung.com.,suffix 424 | clients2.google.com.,suffix 425 | business.google.com.,suffix 426 | grabcad.com.,suffix 427 | vmcdn.com.,suffix 428 | nirsoft.net.,suffix 429 | digikey.com.,suffix 430 | download.01.org.,suffix 431 | packagesource.com.,suffix 432 | wtfismyip.com.,suffix 433 | bootswatch.com.,suffix 434 | getbootstrap.com.,suffix 435 | events.google.com.,suffix 436 | unsplash.com.,suffix 437 | packagecloud.io.,suffix 438 | coursera-apps.org.,suffix 439 | coursera.com.,suffix 440 | javacardos.com.,suffix 441 | getcaddy.com.,suffix 442 | bit.dev.,suffix 443 | clamav.net.,suffix 444 | qt.io.,suffix 445 | forums.cpanel.net.,suffix 446 | nginx.com.,suffix 447 | surveys.google.com.,suffix 448 | jfrog.io.,suffix 449 | yarnpkg.com.,suffix 450 | kaggle.net.,suffix 451 | battlecode.org.,suffix 452 | cljdoc.org.,suffix 453 | vuetifyjs.com.,suffix 454 | wpastra.com.,suffix 455 | zeplin.io.,suffix 456 | xkcd.com.,suffix 457 | deployer.org.,suffix 458 | heroku.com.,suffix 459 | travis-ci.com.,suffix 460 | travis-ci.org.,suffix 461 | hpe.com.,suffix 462 | fedoramagazine.org.,suffix 463 | datastudio.google.com.,suffix 464 | stackshare.io.,suffix 465 | cloudbees.com.,suffix 466 | red.com.,suffix 467 | remove.bg.,suffix 468 | fedoraproject.org.,suffix 469 | consul.io.,suffix 470 | mozilla.org.,suffix 471 | bigbluebutton.org.,suffix 472 | openvpn.net.,suffix 473 | alpinelinux.org.,suffix 474 | yajrabox.com.,suffix 475 | api.daily.dev.,suffix 476 | wakatime.com.,suffix 477 | fontawesome.com.,suffix 478 | webcatalog.app.,suffix 479 | hackthebox.eu.,suffix 480 | segment.com.,suffix 481 | towardsdatascience.com.,suffix 482 | microsoft.com.,suffix 483 | trello.com.,suffix 484 | mariadb.com.,suffix 485 | tabnine.com.,suffix 486 | chromium.org.,suffix 487 | cisco.com.,suffix 488 | nestjs.com.,suffix 489 | envato.market.,suffix 490 | mailchimp.com.,suffix 491 | stripe.network.,suffix 492 | waze.com.,suffix 493 | visualping.io.,suffix 494 | trellocdn.com.,suffix 495 | typoci.com.,suffix 496 | logrocket.com.,suffix 497 | statuspage.io.,suffix 498 | hp.com.,suffix 499 | nodesource.com.,suffix 500 | react-select.com.,suffix 501 | earthengine.google.com.,suffix 502 | fortinet.com.,suffix 503 | leafletjs.com.,suffix 504 | dev.to.,suffix 505 | ojrq.net.,suffix 506 | codepen.io.,suffix 507 | xiaomi.com.,suffix 508 | micropyramid.com.,suffix 509 | dns.google.,suffix 510 | digitalocean.com.,suffix 511 | lit.dev.,suffix 512 | intellij.net.,suffix 513 | altn.com.,suffix 514 | pingdom.com.,suffix 515 | thinkwithgoogle.com.,suffix 516 | githubusercontent.com.,suffix 517 | 000webhost.com.,suffix 518 | dot.tk.,suffix 519 | freenom.com.,suffix 520 | quilljs.com.,suffix 521 | hackerrank.com.,suffix 522 | victoriametrics.com.,suffix 523 | fuchsia.dev.,suffix 524 | highcharts.com.,suffix 525 | fusioncharts.com.,suffix 526 | deno.land.,suffix 527 | minio.io.,suffix 528 | nvcr.io.,suffix 529 | opendev.org.,suffix 530 | pypa.io.,suffix 531 | pythonhosted.org.,suffix 532 | cirros-cloud.net.,suffix 533 | bestbuy.com.,suffix 534 | dashboardpack.com.,suffix 535 | eloqua.com.,suffix 536 | gnome.org.,suffix 537 | google.qualtrics.com.,suffix 538 | lg.com.,suffix 539 | lgappstv.com.,suffix 540 | linuxquestions.org.,suffix 541 | notifications.google.com.,suffix 542 | one.google.com.,suffix 543 | onfastspring.com.,suffix 544 | paddle.com.,suffix 545 | rtings.com.,suffix 546 | templatemag.com.,suffix 547 | themepixels.me.,suffix 548 | wrappixel.com.,suffix 549 | azul.com.,suffix 550 | go.dev.,suffix 551 | plotly.com.,suffix 552 | coreldraw.com.,suffix 553 | intelephense.com.,suffix 554 | itsfoss.com.,suffix 555 | serverpilot.io.,suffix 556 | pytorch.org.,suffix 557 | insomnia.rest.,suffix 558 | planet.com.,suffix 559 | axocdn.com.,suffix 560 | gitkraken.com.,suffix 561 | dellcdn.com.,suffix 562 | instana.com.,suffix 563 | terraform.io.,suffix 564 | registry.terraform.io.,suffix 565 | freepik.com.,suffix 566 | research.google.com.,suffix 567 | netdata.cloud.,suffix 568 | app.netdata.cloud.,suffix 569 | chat.google.com.,suffix 570 | figma.com.,suffix 571 | maxmind.com.,suffix 572 | rabbitmq.com.,suffix 573 | redis.io.,suffix 574 | ansible.com.,suffix 575 | cloudfunctions.net.,suffix 576 | webcatalog.io.,suffix 577 | maxcdn.com.,suffix 578 | visualstudio.com.,suffix 579 | notion.so.,suffix 580 | lens.google.com.,suffix 581 | dart.dev.,suffix 582 | visualstudio.com.,suffix 583 | dartpad.dev.,suffix 584 | registry.k8s.io.,suffix 585 | pkg.dev.,suffix 586 | js.org.,suffix 587 | openai.com.,suffix 588 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/mosajjal/sniproxy/v2 2 | 3 | go 1.23.4 4 | 5 | require ( 6 | github.com/deathowl/go-metrics-prometheus v0.0.0-20221009205350-f2a1482ba35b 7 | github.com/folbricht/routedns v0.1.96 8 | github.com/golang-collections/collections v0.0.0-20130729185459-604e922904d3 9 | github.com/google/uuid v1.6.0 10 | github.com/gorilla/handlers v1.5.2 11 | github.com/knadh/koanf v1.5.0 12 | github.com/m13253/dns-over-https/v2 v2.3.7 13 | github.com/miekg/dns v1.1.62 14 | github.com/mosajjal/doqd v0.0.0-20230911082614-66fb2db2687f 15 | github.com/oschwald/maxminddb-golang v1.13.1 16 | github.com/pkg/profile v1.7.0 17 | github.com/prometheus/client_golang v1.20.5 18 | github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475 19 | github.com/rs/zerolog v1.33.0 20 | github.com/spf13/cobra v1.8.1 21 | github.com/txthinking/socks5 v0.0.0-20230325130024-4230056ae301 22 | github.com/yl2chen/cidranger v1.0.2 23 | golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8 24 | golang.org/x/net v0.38.0 25 | inet.af/tcpproxy v0.0.0-20231102063150-2862066fc2a9 26 | ) 27 | 28 | require ( 29 | github.com/RackSec/srslog v0.0.0-20180709174129-a4725f04ec91 // indirect 30 | github.com/beorn7/perks v1.0.1 // indirect 31 | github.com/cespare/xxhash/v2 v2.3.0 // indirect 32 | github.com/cisco/go-hpke v0.0.0-20230407100446-246075f83609 // indirect 33 | github.com/cisco/go-tls-syntax v0.0.0-20200617162716-46b0cfb76b9b // indirect 34 | github.com/cloudflare/circl v1.5.0 // indirect 35 | github.com/cloudflare/odoh-go v1.0.1-0.20230926114050-f39fa019b017 // indirect 36 | github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect 37 | github.com/felixge/fgprof v0.9.3 // indirect 38 | github.com/felixge/httpsnoop v1.0.4 // indirect 39 | github.com/fsnotify/fsnotify v1.8.0 // indirect 40 | github.com/go-task/slim-sprig/v3 v3.0.0 // indirect 41 | github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad // indirect 42 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 43 | github.com/infobloxopen/go-trees v0.0.0-20221216143356-66ceba885ebc // indirect 44 | github.com/jtacoma/uritemplates v1.0.0 // indirect 45 | github.com/klauspost/compress v1.17.11 // indirect 46 | github.com/mattn/go-colorable v0.1.14 // indirect 47 | github.com/mattn/go-isatty v0.0.20 // indirect 48 | github.com/mitchellh/copystructure v1.2.0 // indirect 49 | github.com/mitchellh/mapstructure v1.5.0 // indirect 50 | github.com/mitchellh/reflectwalk v1.0.2 // indirect 51 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 52 | github.com/onsi/ginkgo/v2 v2.22.2 // indirect 53 | github.com/patrickmn/go-cache v2.1.0+incompatible // indirect 54 | github.com/pion/dtls/v2 v2.2.12 // indirect 55 | github.com/pion/logging v0.2.2 // indirect 56 | github.com/pion/transport/v2 v2.2.10 // indirect 57 | github.com/pion/transport/v3 v3.0.7 // indirect 58 | github.com/pkg/errors v0.9.1 // indirect 59 | github.com/prometheus/client_model v0.6.1 // indirect 60 | github.com/prometheus/common v0.61.0 // indirect 61 | github.com/prometheus/procfs v0.15.1 // indirect 62 | github.com/quic-go/qpack v0.5.1 // indirect 63 | github.com/quic-go/quic-go v0.48.2 // indirect 64 | github.com/redis/go-redis/v9 v9.7.3 // indirect 65 | github.com/spf13/pflag v1.0.5 // indirect 66 | github.com/txthinking/runnergroup v0.0.0-20241229123329-7b873ad00768 // indirect 67 | go.uber.org/mock v0.5.0 // indirect 68 | golang.org/x/crypto v0.36.0 // indirect 69 | golang.org/x/mod v0.22.0 // indirect 70 | golang.org/x/sync v0.12.0 // indirect 71 | golang.org/x/sys v0.31.0 // indirect 72 | golang.org/x/text v0.23.0 // indirect 73 | golang.org/x/tools v0.29.0 // indirect 74 | google.golang.org/protobuf v1.36.2 // indirect 75 | gopkg.in/yaml.v3 v3.0.1 // indirect 76 | ) 77 | -------------------------------------------------------------------------------- /install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | # check distro and root 5 | if [ -f /etc/debian_version ]; then 6 | echo "Debian/Ubuntu detected" 7 | if [ "$(id -u)" != "0" ]; then 8 | echo "This script must be run as root" 1>&2 9 | exit 1 10 | fi 11 | elif [ -f /etc/redhat-release ]; then 12 | echo "Redhat/CentOS detected" 13 | if [ "$(id -u)" != "0" ]; then 14 | echo "This script must be run as root" 1>&2 15 | exit 1 16 | fi 17 | else 18 | echo "Unsupported distro" 19 | exit 1 20 | fi 21 | 22 | # check to see if the OS is systemd-based 23 | if [ ! -d /run/systemd/system ]; then 24 | echo "Systemd not detected, exiting" 25 | exit 1 26 | fi 27 | 28 | # prompt before removing stub resolver 29 | echo "This script will remove the stub resolver from /etc/resolv.conf" 30 | echo "and replace it with 9.9.9.9" 31 | echo "Press Ctrl-C to abort or Enter to replace the DNS server with 9.9.9.9, otherwise enter your preffered DNS server and press Enter" 32 | read dnsServer 33 | 34 | # if dnsServer is empty, replace it with 9.9.9.9 35 | if [ -z "$dnsServer" ]; then 36 | dnsServer="9.9.9.9" 37 | fi 38 | 39 | # check to see if sed is installed 40 | if ! command -v sed &> /dev/null; then 41 | echo "sed could not be found" 42 | exit 1 43 | fi 44 | 45 | # remove stub resolver 46 | sed -i 's/#DNS=/DNS='$dnsServer'/; s/#DNSStubListener=yes/DNSStubListener=no/' /etc/systemd/resolved.conf 47 | systemctl restart systemd-resolved 48 | 49 | # check if stub resolver is removed by checking netstat for port 53 udp. try both ss and netstat 50 | # try ss first, if it's not installed, try netstat 51 | if command -v ss &> /dev/null; then 52 | if ss -lun '( dport = :53 )' | grep -q 53; then 53 | echo "stub resolver is not removed" 54 | exit 1 55 | fi 56 | elif command -v netstat &> /dev/null; then 57 | if netstat -lun '( dport = :53 )' | grep -q 53; then 58 | echo "stub resolver is not removed. maybe sniproxy is already installed?" 59 | exit 1 60 | fi 61 | else 62 | echo "ss and netstat could not be found" 63 | exit 1 64 | fi 65 | 66 | # create a folder under /opt for sniproxy 67 | mkdir -p /opt/sniproxy 68 | 69 | execCommand="/opt/sniproxy/sniproxy" 70 | configPath="/opt/sniproxy/sniproxy.yaml" 71 | yqPath="/opt/sniproxy/yq" 72 | 73 | # download sniproxy 74 | curl -L -o $execCommand http://bin.n0p.me/sniproxy 75 | # make it executable 76 | chmod +x $execCommand 77 | 78 | # download yq 79 | curl -L -o $yqPath http://bin.n0p.me/yq 80 | # make it executable 81 | chmod +x $yqPath 82 | 83 | # generate the default config 84 | $execCommand --defaultconfig > $configPath 85 | 86 | # ask which domains to proxy 87 | echo "sniproxy can proxy all HTTPS traffic or only specific domains, if you have a domain list URL, enter it below, otherwise press Enter to proxy all HTTPS traffic" 88 | read domainlist 89 | 90 | # if domainslist is not empty, there should be a --domainListPath argument added to sniproxy execute command 91 | if [ -n "$domainlist" ]; then 92 | $yqPath -i '.acl.domain.enabled = true, .acl.domain.path = '"$domainlist" $configPath 93 | fi 94 | 95 | # ask if DNS over TCP should be enabled 96 | echo "Do you want to enable DNS over TCP? (y/n)" 97 | read dnsOverTCP 98 | # if yes, add --bindDnsOverTcp argument to sniproxy execute command 99 | if [ "$dnsOverTCP" = "y" ]; then 100 | $yqPath -i '.general.bind_dns_over_tcp = "0.0.0.0:53"' $configPath 101 | fi 102 | 103 | # ask if DNS over TLS should be enabled 104 | echo "Do you want to enable DNS over TLS? (y/n)" 105 | read dnsOverTLS 106 | # if yes, add --bindDnsOverTls argument to sniproxy execute command 107 | if [ "$dnsOverTLS" = "y" ]; then 108 | $yqPath -i '.general.bind_dns_over_tls = "0.0.0.0:853"' $configPath 109 | fi 110 | 111 | # ask for DNS over QUIC 112 | echo "Do you want to enable DNS over QUIC? (y/n)" 113 | read dnsOverQUIC 114 | # if yes, add --bindDnsOverQuic argument to sniproxy execute command 115 | if [ "$dnsOverQUIC" = "y" ]; then 116 | $yqPath -i '.general.bind_dns_over_quic = "0.0.0.0:8853"' $configPath 117 | fi 118 | 119 | # if any of DNS over TLS or DNS over QUIC is enabled, ask for the certificate path and key path 120 | if [ "$dnsOverTLS" = "y" ] || [ "$dnsOverQUIC" = "y" ]; then 121 | echo "Enter the path to the certificate file, if you don't have one, press Enter to use a self-signed certificate" 122 | read certPath 123 | echo "Enter the path to the key file, if you don't have one, press Enter to use a self-signed certificate" 124 | read keyPath 125 | 126 | # if any of the paths are empty, omit both arguments and print a warning for self-signed certificates 127 | if [ -z "$certPath" ] || [ -z "$keyPath" ]; then 128 | echo "WARNING: Using self-signed certificates" 129 | else 130 | $yqPath -i '.general.tls_cert = "$certPath", .general.tls_key = "$keyPath"' $configPath 131 | fi 132 | fi 133 | 134 | # create a systemd service file 135 | cat < /etc/systemd/system/sniproxy.service 136 | [Unit] 137 | Description=sniproxy 138 | After=network.target 139 | 140 | [Service] 141 | Type=simple 142 | ExecStart=$execCommand --config $configPath 143 | Restart=on-failure 144 | 145 | [Install] 146 | WantedBy=multi-user.target 147 | EOF 148 | 149 | # enable and start the service 150 | systemctl enable sniproxy 151 | systemctl start sniproxy 152 | 153 | # check if sniproxy is running 154 | if systemctl is-active --quiet sniproxy; then 155 | echo "sniproxy is running" 156 | else 157 | echo "sniproxy is not running" 158 | fi 159 | 160 | # get the public IP of the server by curl 4.ident.me 161 | publicIP=$(curl -s 4.ident.me) 162 | 163 | # print some instructions for setting up DNS in clients to this 164 | echo "sniproxy is now running, you can set up DNS in your clients to $publicIP" 165 | echo "you can check the status of sniproxy by running: sudo systemctl status sniproxy" 166 | echo "you can check the logs of sniproxy by running: sudo journalctl -u sniproxy" 167 | echo "some of the features of sniproxy are not covered by this script, please refer to the GitHub page for more information: github.com/moasjjal/sniproxy" 168 | 169 | echo "if journal shows empty, you might need to reboot the server, sniproxy is set up as a service so it should start automatically after reboot" 170 | 171 | # we're done 172 | echo "Done" 173 | exit 0 174 | -------------------------------------------------------------------------------- /pkg/acl/acl.go: -------------------------------------------------------------------------------- 1 | // Package acl contains the logic for Access Control Lists. It provides a way to make decisions based on the connection information. 2 | package acl 3 | 4 | import ( 5 | "fmt" 6 | "net" 7 | "sort" 8 | 9 | "github.com/knadh/koanf" 10 | "github.com/rs/zerolog" 11 | ) 12 | 13 | // Decision is the type of decision that an ACL can make for each connection info 14 | type Decision string 15 | 16 | const ( 17 | // Accept shows the indifference of the ACL to the connection 18 | Accept Decision = "Accept" 19 | // Reject shows that the ACL has rejected the connection. each ACL should check this before proceeding to check the connection against its rules 20 | Reject Decision = "Reject" 21 | // ProxyIP shows that the ACL has decided to proxy the connection through sniproxy rather than the origin IP 22 | ProxyIP Decision = "ProxyIP" 23 | // OriginIP shows that the ACL has decided to proxy the connection through the origin IP rather than sniproxy 24 | OriginIP Decision = "OriginIP" 25 | // Override shows that the ACL has decided to override the connection and proxy it through the specified DstIP and DstPort 26 | Override Decision = "Override" 27 | ) 28 | 29 | // ConnInfo contains all the information about a connection that is available 30 | // it also serves as an ACL enforcer in a sense that if IsRejected is set to true 31 | // the connection is dropped 32 | type ConnInfo struct { 33 | SrcIP net.Addr // this is more of a source socket than just IP. 34 | DstIP net.TCPAddr 35 | Domain string 36 | Decision 37 | } 38 | 39 | type byPriority []ACL 40 | 41 | func (a byPriority) Len() int { return len(a) } 42 | func (a byPriority) Swap(i, j int) { a[i], a[j] = a[j], a[i] } 43 | func (a byPriority) Less(i, j int) bool { return a[i].Priority() < a[j].Priority() } 44 | 45 | // ACL is the interface that each ACL should implement 46 | type ACL interface { 47 | Decide(*ConnInfo) error 48 | Name() string 49 | Priority() uint 50 | ConfigAndStart(*zerolog.Logger, *koanf.Koanf) error 51 | } 52 | 53 | // StartACLs starts all the ACLs that have been configured and registered 54 | func StartACLs(log *zerolog.Logger, k *koanf.Koanf) ([]ACL, error) { 55 | var a []ACL 56 | aclK := k.Cut("acl") 57 | for _, acl := range availableACLs { 58 | // cut each konaf based on the name of the ACL 59 | // only configure if the "enabled" key is set to true 60 | if !aclK.Bool(fmt.Sprintf("%s.enabled", (acl).Name())) { 61 | continue 62 | } 63 | var l = log.With().Str("acl", (acl).Name()).Logger() 64 | // we pass the full config to each ACL so that they can cut it themselves. it's needed for some ACLs that need 65 | // to read the config of other ACLs or the global config 66 | if err := acl.ConfigAndStart(&l, k); err != nil { 67 | log.Warn().Msgf("failed to start ACL %s with error %s", (acl).Name(), err) 68 | return a, err 69 | } 70 | a = append(a, acl) 71 | log.Info().Msgf("started ACL: '%s'", (acl).Name()) 72 | 73 | } 74 | return a, nil 75 | } 76 | 77 | // MakeDecision loops through all the ACLs and makes a decision for the connection 78 | func MakeDecision(c *ConnInfo, a []ACL) error { 79 | sort.Sort(byPriority(a)) 80 | for _, acl := range a { 81 | if err := acl.Decide(c); err != nil { 82 | return err 83 | } 84 | } 85 | // if ACL list is empty, we accept the connection 86 | if len(a) == 0 { 87 | c.Decision = Accept 88 | } 89 | return nil 90 | } 91 | 92 | // each ACL should register itself by appending itself to this list 93 | var availableACLs []ACL 94 | -------------------------------------------------------------------------------- /pkg/acl/acl_test.go: -------------------------------------------------------------------------------- 1 | package acl 2 | 3 | import ( 4 | "net" 5 | "os" 6 | "testing" 7 | "time" 8 | 9 | "github.com/knadh/koanf" 10 | "github.com/knadh/koanf/parsers/yaml" 11 | "github.com/knadh/koanf/providers/rawbytes" 12 | "github.com/rs/zerolog" 13 | ) 14 | 15 | var logger = zerolog.New(os.Stderr).With().Timestamp().Logger().Output(zerolog.ConsoleWriter{Out: os.Stderr, TimeFormat: time.RFC3339, NoColor: true}) 16 | 17 | var configs = map[string]string{ 18 | "acl_domain.yaml": ` 19 | acl: 20 | domain: 21 | enabled: true 22 | priority: 20 23 | path: ../../domains.csv 24 | refresh_interval: 1h0m0s`, 25 | "acl_cidr.yaml": ` 26 | acl: 27 | cidr: 28 | enabled: true 29 | priority: 30 30 | path: ../../cidr.csv 31 | refresh_interval: 1h0m0s`, 32 | "acl_domain_cidr.yaml": ` 33 | acl: 34 | domain: 35 | enabled: true 36 | priority: 20 37 | path: ../../domains.csv 38 | refresh_interval: 1h0m0s 39 | cidr: 40 | enabled: true 41 | priority: 30 42 | path: ../../cidr.csv 43 | refresh_interval: 1h0m0s`, 44 | "acl_cidr_domain.yaml": ` 45 | acl: 46 | domain: 47 | enabled: true 48 | priority: 20 49 | path: ../../domains.csv 50 | refresh_interval: 1h0m0s 51 | cidr: 52 | enabled: true 53 | priority: 19 54 | path: ../../cidr.csv 55 | refresh_interval: 1h0m0s`, 56 | } 57 | 58 | func TestMakeDecision(t *testing.T) { 59 | // Test cases 60 | cases := []struct { 61 | connInfo *ConnInfo 62 | config string 63 | expected Decision 64 | }{ 65 | { 66 | // domain in domains.csv 67 | connInfo: mockConnInfo("1.1.1.1", "ipinfo.io"), 68 | config: configs["acl_domain.yaml"], 69 | expected: ProxyIP, 70 | }, 71 | { 72 | // domain NOT in domains.csv 73 | connInfo: mockConnInfo("2.2.2.2", "google.de"), 74 | config: configs["acl_domain.yaml"], 75 | expected: OriginIP, 76 | }, 77 | { 78 | // ip REJECT in cidr.csv 79 | // if you want to whitelist IPs then you must include "0.0.0.0/0,reject" otherwise always accepted!! 80 | connInfo: mockConnInfo("1.1.1.1", "google.de"), 81 | config: configs["acl_cidr.yaml"], 82 | expected: Reject, 83 | }, 84 | { 85 | // ip ACCEPT in cidr.csv 86 | connInfo: mockConnInfo("77.77.1.1", "google.de"), 87 | config: configs["acl_cidr.yaml"], 88 | expected: Accept, 89 | }, 90 | { 91 | // ip ACCEPT in cidr.csv, still no ProxyIP (acl.domain not enabled) 92 | connInfo: mockConnInfo("77.77.1.1", "ipinfo.io"), 93 | config: configs["acl_cidr.yaml"], 94 | expected: Accept, 95 | }, 96 | { 97 | // domain in domains.csv, ip ACCEPT in cidr.csv 98 | connInfo: mockConnInfo("77.77.1.1", "ipinfo.io"), 99 | config: configs["acl_domain_cidr.yaml"], 100 | expected: ProxyIP, 101 | }, 102 | { 103 | // domain NOT in domains.csv, ip ACCEPT in cidr.csv 104 | connInfo: mockConnInfo("77.77.1.1", "google.de"), 105 | config: configs["acl_domain_cidr.yaml"], 106 | expected: OriginIP, 107 | }, 108 | { 109 | // domain in domains.csv, ip REJECT in cidr.csv 110 | connInfo: mockConnInfo("1.1.1.1", "ipinfo.io"), 111 | config: configs["acl_domain_cidr.yaml"], 112 | expected: Reject, // still returns OriginIP in DNS !!! 113 | }, 114 | { 115 | // domain NOT in domains.csv, ip REJECT in cidr.csv 116 | connInfo: mockConnInfo("1.1.1.1", "google.de"), 117 | config: configs["acl_domain_cidr.yaml"], 118 | expected: Reject, // still returns OriginIP in DNS !!! 119 | }, 120 | { 121 | // domain NOT in domains.csv, ip ACCEPT in cidr.csv 122 | connInfo: mockConnInfo("77.77.1.1", "google.de"), 123 | config: configs["acl_cidr_domain.yaml"], 124 | expected: OriginIP, 125 | }, 126 | { 127 | // domain in domains.csv, ip ACCEPT in cidr.csv 128 | connInfo: mockConnInfo("77.77.1.1", "ipinfo.io"), 129 | config: configs["acl_cidr_domain.yaml"], 130 | expected: ProxyIP, 131 | }, 132 | { 133 | // domain in domains.csv, ip REJECT in cidr.csv 134 | connInfo: mockConnInfo("1.1.1.1", "google.de"), 135 | config: configs["acl_cidr_domain.yaml"], 136 | expected: Reject, 137 | }, 138 | { 139 | // domain NOT in domains.csv, ip REJECT in cidr.csv 140 | connInfo: mockConnInfo("1.1.1.1", "google.de"), 141 | config: configs["acl_cidr_domain.yaml"], 142 | expected: Reject, 143 | }, 144 | } 145 | 146 | // Run the test cases 147 | for _, tc := range cases { 148 | t.Run(tc.config, func(t *testing.T) { 149 | MakeDecision(tc.connInfo, getAcls(&logger, tc.config)) 150 | if tc.expected != tc.connInfo.Decision { 151 | t.Errorf("MakeDecision (domain=%v,ip=%v,config=%v) decided %v, expected %v", tc.connInfo.Domain, tc.connInfo.SrcIP, tc.config, tc.connInfo.Decision, tc.expected) 152 | } 153 | }) 154 | } 155 | } 156 | 157 | // TestReverse tests the reverse function 158 | func TestReverse(t *testing.T) { 159 | tests := []struct { 160 | name string 161 | s string 162 | want string 163 | }{ 164 | {name: "test1", s: "abc", want: "cba"}, 165 | {name: "test2", s: "a", want: "a"}, 166 | {name: "test3", s: "aab", want: "baa"}, 167 | {name: "test4", s: "zzZ", want: "Zzz"}, 168 | {name: "test5", s: "ab2", want: "2ba"}, 169 | } 170 | for _, tt := range tests { 171 | t.Run(tt.name, func(t *testing.T) { 172 | if got := reverse(tt.s); got != tt.want { 173 | t.Errorf("reverse() = %v, want %v", got, tt.want) 174 | } 175 | }) 176 | } 177 | } 178 | 179 | func getAcls(log *zerolog.Logger, config string) []ACL { 180 | var k = koanf.New(".") 181 | if err := k.Load(rawbytes.Provider([]byte(config)), yaml.Parser()); err != nil { 182 | log.Fatal().Msgf("error loading config file: %v", err) 183 | } 184 | a, err := StartACLs(&logger, k) 185 | if err != nil { 186 | panic(err) 187 | } 188 | // we need this to give acl time to (re)load 189 | time.Sleep(1 * time.Second) 190 | return a 191 | } 192 | 193 | func mockConnInfo(srcIP string, domain string) *ConnInfo { 194 | addr, err := net.ResolveTCPAddr("tcp", srcIP+":80") 195 | 196 | if err != nil { 197 | logger.Fatal().Msgf("error parsing ip from string: %v", err) 198 | } 199 | 200 | return &ConnInfo{ 201 | SrcIP: addr, 202 | Domain: domain, 203 | } 204 | } 205 | -------------------------------------------------------------------------------- /pkg/acl/cidr.go: -------------------------------------------------------------------------------- 1 | package acl 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "net" 7 | "net/http" 8 | "os" 9 | "strings" 10 | "time" 11 | 12 | "github.com/knadh/koanf" 13 | "github.com/rs/zerolog" 14 | "github.com/yl2chen/cidranger" 15 | ) 16 | 17 | // CIDR acl allows sniproxy to use a list of CIDR to allow or reject connections 18 | // The list is loaded from a file or URL and refreshed periodically 19 | // The list is a CSV file with the CIDR in the first column and the policy in the second 20 | // The policy can be allow or reject and defaults to reject 21 | type cidr struct { 22 | Path string `yaml:"path"` 23 | RefreshInterval time.Duration `yaml:"refresh_interval"` 24 | AllowRanger cidranger.Ranger 25 | RejectRanger cidranger.Ranger 26 | logger *zerolog.Logger 27 | priority uint 28 | } 29 | 30 | func (d *cidr) LoadCIDRCSV(path string) error { 31 | d.AllowRanger = cidranger.NewPCTrieRanger() 32 | d.RejectRanger = cidranger.NewPCTrieRanger() 33 | 34 | d.logger.Info().Msg("Loading the CIDR from file/url") 35 | var scanner *bufio.Scanner 36 | if strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") { 37 | d.logger.Info().Msg("CIDR list is a URL, trying to fetch") 38 | client := http.Client{ 39 | CheckRedirect: func(r *http.Request, _ []*http.Request) error { 40 | r.URL.Opaque = r.URL.Path 41 | return nil 42 | }, 43 | } 44 | resp, err := client.Get(path) 45 | if err != nil { 46 | d.logger.Error().Msg(err.Error()) 47 | return err 48 | } 49 | d.logger.Info().Msgf("(re)fetching URL: %s", path) 50 | defer resp.Body.Close() 51 | scanner = bufio.NewScanner(resp.Body) 52 | 53 | } else { 54 | file, err := os.Open(path) 55 | if err != nil { 56 | return err 57 | } 58 | d.logger.Info().Msgf("(re)loading file: %s", path) 59 | defer file.Close() 60 | scanner = bufio.NewScanner(file) 61 | } 62 | 63 | for scanner.Scan() { 64 | row := scanner.Text() 65 | // cut the line at the first comma 66 | cidr, policy, found := strings.Cut(row, ",") 67 | if !found { 68 | d.logger.Info().Msg(cidr + " is not a valid csv line, assuming reject") 69 | } 70 | if policy == "allow" { 71 | if _, netw, err := net.ParseCIDR(cidr); err == nil { 72 | _ = d.AllowRanger.Insert(cidranger.NewBasicRangerEntry(*netw)) 73 | } else { 74 | if _, netw, err := net.ParseCIDR(cidr + "/32"); err == nil { 75 | _ = d.AllowRanger.Insert(cidranger.NewBasicRangerEntry(*netw)) 76 | } else { 77 | d.logger.Error().Msg(err.Error()) 78 | } 79 | } 80 | } else { 81 | if _, netw, err := net.ParseCIDR(cidr); err == nil { 82 | _ = d.RejectRanger.Insert(cidranger.NewBasicRangerEntry(*netw)) 83 | } else { 84 | if _, netw, err := net.ParseCIDR(cidr + "/32"); err == nil { 85 | _ = d.RejectRanger.Insert(cidranger.NewBasicRangerEntry(*netw)) 86 | } else { 87 | d.logger.Error().Msg(err.Error()) 88 | } 89 | } 90 | } 91 | } 92 | d.logger.Info().Msgf("%d cidr(s) loaded", d.AllowRanger.Len()) 93 | 94 | return nil 95 | } 96 | 97 | func (d *cidr) loadCIDRCSVWorker() { 98 | for { 99 | _ = d.LoadCIDRCSV(d.Path) 100 | time.Sleep(d.RefreshInterval) 101 | } 102 | } 103 | 104 | // Decide checks if the connection is allowed or rejected 105 | func (d cidr) Decide(c *ConnInfo) error { 106 | d.logger.Debug().Any("conn", c).Msg("deciding on cidr acl") 107 | // get the IP from the connection 108 | ipPort := strings.Split(c.SrcIP.String(), ":") 109 | ip := net.ParseIP(ipPort[0]) 110 | 111 | prevDec := c.Decision 112 | // set the prev decision to accept if it's empty 113 | if prevDec == "" { 114 | prevDec = Accept 115 | } 116 | 117 | if match, err := d.RejectRanger.Contains(ip); match && err == nil { 118 | c.Decision = Reject 119 | } 120 | if match, err := d.AllowRanger.Contains(ip); match && err == nil { 121 | c.Decision = prevDec 122 | } 123 | return nil 124 | } 125 | 126 | // Name function is used to cut the YAML config file to be passed on to the ACL for config 127 | func (d cidr) Name() string { 128 | return "cidr" 129 | } 130 | func (d cidr) Priority() uint { 131 | return d.priority 132 | } 133 | 134 | // Config function is what starts the ACL 135 | func (d *cidr) ConfigAndStart(logger *zerolog.Logger, c *koanf.Koanf) error { 136 | c = c.Cut(fmt.Sprintf("acl.%s", d.Name())) 137 | d.logger = logger 138 | d.Path = c.String("path") 139 | d.priority = uint(c.Int("priority")) 140 | d.RefreshInterval = c.Duration("refresh_interval") 141 | go d.loadCIDRCSVWorker() 142 | return nil 143 | } 144 | 145 | // make the acl available at import time 146 | func init() { 147 | availableACLs = append(availableACLs, &cidr{}) 148 | } 149 | -------------------------------------------------------------------------------- /pkg/acl/domain.go: -------------------------------------------------------------------------------- 1 | package acl 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "net" 7 | "net/http" 8 | "os" 9 | "strings" 10 | "time" 11 | 12 | "github.com/golang-collections/collections/tst" 13 | "github.com/knadh/koanf" 14 | "github.com/rs/zerolog" 15 | ) 16 | 17 | // domain ACL makes a decision on a connection based on the domain name derived 18 | // from client hello's SNI. It can be used to skip the sni proxy for certain domains 19 | type domain struct { 20 | Path string `yaml:"domain.path"` 21 | RefreshInterval time.Duration `yaml:"domain.refresh_interval"` 22 | routePrefixes *tst.TernarySearchTree 23 | routeSuffixes *tst.TernarySearchTree 24 | routeFQDNs map[string]uint8 25 | logger *zerolog.Logger 26 | priority uint 27 | } 28 | 29 | const ( 30 | matchPrefix = uint8(1) 31 | matchSuffix = uint8(2) 32 | matchFQDN = uint8(3) 33 | ) 34 | 35 | // inDomainList returns true if the domain is meant to be SKIPPED and not go through sni proxy 36 | func (d domain) inDomainList(fqdn string) bool { 37 | if !strings.HasSuffix(fqdn, ".") { 38 | fqdn = fqdn + "." 39 | } 40 | fqdnLower := strings.ToLower(fqdn) 41 | // check for fqdn match 42 | if d.routeFQDNs[fqdnLower] == matchFQDN { 43 | return false 44 | } 45 | // check for prefix match 46 | if longestPrefix := d.routePrefixes.GetLongestPrefix(fqdnLower); longestPrefix != nil { 47 | // check if the longest prefix is present in the type hashtable as a prefix 48 | if d.routeFQDNs[longestPrefix.(string)] == matchPrefix { 49 | return false 50 | } 51 | } 52 | // check for suffix match. Note that suffix is just prefix reversed 53 | if longestSuffix := d.routeSuffixes.GetLongestPrefix(reverse(fqdnLower)); longestSuffix != nil { 54 | // check if the longest suffix is present in the type hashtable as a suffix 55 | if d.routeFQDNs[longestSuffix.(string)] == matchSuffix { 56 | return false 57 | } 58 | } 59 | return true 60 | } 61 | 62 | func reverse(s string) string { 63 | r := []rune(s) 64 | for i, j := 0, len(r)-1; i < len(r)/2; i, j = i+1, j-1 { 65 | r[i], r[j] = r[j], r[i] 66 | } 67 | return string(r) 68 | } 69 | 70 | // LoadDomainsCsv loads a domains Csv file/URL. returns 3 parameters: 71 | // 1. a TST for all the prefixes (type 1) 72 | // 2. a TST for all the suffixes (type 2) 73 | // 3. a hashtable for all the full match fqdn (type 3) 74 | func (d *domain) LoadDomainsCsv(Filename string) error { 75 | d.logger.Info().Msg("Loading the domain from file/url") 76 | var scanner *bufio.Scanner 77 | if strings.HasPrefix(Filename, "http://") || strings.HasPrefix(Filename, "https://") { 78 | d.logger.Info().Msg("domain list is a URL, trying to fetch") 79 | client := http.Client{ 80 | CheckRedirect: func(r *http.Request, _ []*http.Request) error { 81 | r.URL.Opaque = r.URL.Path 82 | return nil 83 | }, 84 | } 85 | resp, err := client.Get(Filename) 86 | if err != nil { 87 | d.logger.Error().Msg(err.Error()) 88 | return err 89 | } 90 | d.logger.Info().Msgf("(re)fetching URL: %s", Filename) 91 | defer resp.Body.Close() 92 | scanner = bufio.NewScanner(resp.Body) 93 | 94 | } else { 95 | file, err := os.Open(Filename) 96 | if err != nil { 97 | return err 98 | } 99 | d.logger.Info().Msgf("(re)loading file: %s", Filename) 100 | defer file.Close() 101 | scanner = bufio.NewScanner(file) 102 | } 103 | for scanner.Scan() { 104 | lowerCaseLine := strings.ToLower(scanner.Text()) 105 | // split the line by comma to understand thed.logger.c 106 | fqdn := strings.Split(lowerCaseLine, ",") 107 | if len(fqdn) != 2 { 108 | d.logger.Info().Msg(lowerCaseLine + " is not a valid line, assuming FQDN") 109 | fqdn = []string{lowerCaseLine, "fqdn"} 110 | } 111 | // add the fqdn to the hashtable with its type 112 | switch entryType := fqdn[1]; entryType { 113 | case "prefix": 114 | d.routeFQDNs[fqdn[0]] = matchPrefix 115 | d.routePrefixes.Insert(fqdn[0], fqdn[0]) 116 | case "suffix": 117 | d.routeFQDNs[fqdn[0]] = matchSuffix 118 | // suffix match is much faster if we reverse the strings and match for prefix 119 | d.routeSuffixes.Insert(reverse(fqdn[0]), fqdn[0]) 120 | case "fqdn": 121 | d.routeFQDNs[fqdn[0]] = matchFQDN 122 | default: 123 | //d.logger.Warnf("%s is not a valid line, assuming fqdn", lowerCaseLine) 124 | d.logger.Info().Msg(lowerCaseLine + " is not a valid line, assuming FQDN") 125 | d.routeFQDNs[fqdn[0]] = matchFQDN 126 | } 127 | } 128 | d.logger.Info().Msgf("%s loaded with %d prefix, %d suffix and %d fqdn", Filename, d.routePrefixes.Len(), d.routeSuffixes.Len(), len(d.routeFQDNs)-d.routePrefixes.Len()-d.routeSuffixes.Len()) 129 | 130 | return nil 131 | } 132 | 133 | func (d *domain) LoadDomainsCSVWorker() { 134 | for { 135 | d.LoadDomainsCsv(d.Path) 136 | time.Sleep(d.RefreshInterval) 137 | } 138 | } 139 | 140 | // implement domain as an ACL interface 141 | func (d domain) Decide(c *ConnInfo) error { 142 | d.logger.Debug().Any("conn", c).Msg("deciding on domain acl") 143 | 144 | if c.Decision == Reject { 145 | c.DstIP = net.TCPAddr{IP: net.IPv4zero, Port: 0} 146 | d.logger.Debug().Any("conn", c).Msg("decided on domain acl") 147 | return nil 148 | } 149 | if d.inDomainList(c.Domain) { 150 | d.logger.Debug().Msgf("domain not going through proxy: %s", c.Domain) 151 | c.Decision = OriginIP 152 | } else { 153 | d.logger.Debug().Msgf("domain going through proxy: %s", c.Domain) 154 | c.Decision = ProxyIP 155 | } 156 | d.logger.Debug().Any("conn", c).Msg("decided on domain acl") 157 | return nil 158 | } 159 | func (d domain) Name() string { 160 | return "domain" 161 | } 162 | func (d domain) Priority() uint { 163 | return d.priority 164 | } 165 | 166 | func (d *domain) ConfigAndStart(logger *zerolog.Logger, c *koanf.Koanf) error { 167 | c = c.Cut(fmt.Sprintf("acl.%s", d.Name())) 168 | d.logger = logger 169 | d.routePrefixes = tst.New() 170 | d.routeSuffixes = tst.New() 171 | d.routeFQDNs = make(map[string]uint8) 172 | d.Path = c.String("path") 173 | d.priority = uint(c.Int("priority")) 174 | d.RefreshInterval = c.Duration("refresh_interval") 175 | go d.LoadDomainsCSVWorker() 176 | return nil 177 | } 178 | 179 | // make domain available to the ACL system at import time 180 | func init() { 181 | availableACLs = append(availableACLs, &domain{}) 182 | } 183 | -------------------------------------------------------------------------------- /pkg/acl/geoip.go: -------------------------------------------------------------------------------- 1 | package acl 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "net" 7 | "net/http" 8 | "os" 9 | "strings" 10 | "time" 11 | 12 | "github.com/knadh/koanf" 13 | "github.com/oschwald/maxminddb-golang" 14 | "github.com/rs/zerolog" 15 | "golang.org/x/exp/slices" 16 | ) 17 | 18 | // geoIP is an ACL that checks the geolocation of incoming connections and 19 | // allows or rejects them based on the country of origin. It uses an MMDB 20 | // database file to determine the country of origin. 21 | // unlike ip and domain ACLs, geoIP does not load the list of countries 22 | // from a CSV file. Instead it reads the country codes to reject or accept 23 | // from the YAML configuration. This is due to the fact that domain and IP 24 | // lists could be loaded from external resources and could be highly dynamic 25 | // whereas geoip restrictions are usually static. 26 | type geoIP struct { 27 | Path string 28 | AllowedCountries []string 29 | BlockedCountries []string 30 | Refresh time.Duration 31 | mmdb *maxminddb.Reader 32 | logger *zerolog.Logger 33 | priority uint 34 | } 35 | 36 | func toLowerSlice(in []string) (out []string) { 37 | for _, v := range in { 38 | out = append(out, strings.ToLower(v)) 39 | } 40 | return 41 | } 42 | 43 | // getCountry returns the country code for the given IP address in ISO format 44 | func (g geoIP) getCountry(ipAddr string) (string, error) { 45 | ip := net.ParseIP(ipAddr) 46 | var record struct { 47 | Country struct { 48 | ISOCode string `maxminddb:"iso_code"` 49 | } `maxminddb:"country"` 50 | } // Or any appropriate struct 51 | 52 | err := g.mmdb.Lookup(ip, &record) 53 | if err != nil { 54 | return "", err 55 | } 56 | return record.Country.ISOCode, nil 57 | } 58 | 59 | // initializeGeoIP loads the geolocation database from the specified g.Path. 60 | func (g *geoIP) initializeGeoIP() error { 61 | 62 | g.logger.Info().Msg("loading the geoip db from file/url") 63 | var scanner []byte 64 | if strings.HasPrefix(g.Path, "http://") || strings.HasPrefix(g.Path, "https://") { 65 | g.logger.Info().Msg("geoip db path is a URL, trying to fetch") 66 | resp, err := http.Get(g.Path) 67 | if err != nil { 68 | return err 69 | } 70 | g.logger.Info().Msgf("(re)fetching %s", g.Path) 71 | defer resp.Body.Close() 72 | scanner, err = io.ReadAll(resp.Body) 73 | if err != nil { 74 | return err 75 | } 76 | 77 | } else { 78 | g.logger.Info().Msgf("(re)loading file: %s", g.Path) 79 | var err error 80 | if scanner, err = os.ReadFile(g.Path); err != nil { 81 | return err 82 | } 83 | } 84 | g.logger.Info().Msgf("geolocation database with %d bytes loaded", len(scanner)) 85 | var err error 86 | if g.mmdb, err = maxminddb.FromBytes(scanner); err != nil { 87 | //g.logger.Warn("%d bytes read, %s", len(scanner), err) 88 | return err 89 | } 90 | g.logger.Info().Msg("Loaded MMDB") 91 | for range time.NewTicker(g.Refresh).C { 92 | if g.mmdb, err = maxminddb.FromBytes(scanner); err != nil { 93 | //g.logger.Warn("%d bytes read, %s", len(scanner), err) 94 | return err 95 | } 96 | g.logger.Info().Msgf("Loaded MMDB %v", g.mmdb) 97 | } 98 | return nil 99 | } 100 | 101 | // checkGeoIPSkip checks an IP address against the exclude and include lists and returns 102 | // true if the IP address should be allowed to pass through. 103 | // the logic is as follows: 104 | // 1. if mmdb is not loaded or not available, it's fail-open (allow by default) 105 | // 2. if the IP can't be resolved to a country, it's rejected 106 | // 3. if the country is in the blocked list, it's rejected 107 | // 4. if the country is in the allowed list, it's allowed 108 | // note that the reject list is checked first and takes priority over the allow list 109 | // if the IP's country doesn't match any of the above, it's allowed if the blocked list is not empty 110 | // for example, if the blockedlist is [US] and the allowedlist is empty, a connection from 111 | // CA will be allowed. but if blockedlist is empty and allowedlist is [US], a connection from 112 | // CA will be rejected. 113 | func (g geoIP) checkGeoIPSkip(addr net.Addr) bool { 114 | if g.mmdb == nil { 115 | return true 116 | } 117 | 118 | ipPort := strings.Split(addr.String(), ":") 119 | ip := ipPort[0] 120 | 121 | var country string 122 | country, err := g.getCountry(ip) 123 | country = strings.ToLower(country) 124 | g.logger.Debug().Msgf("incoming tcp connection from ip %s and country %s", ip, country) 125 | 126 | if err != nil { 127 | g.logger.Info().Msgf("failed to get the geolocation of ip %s", ip) 128 | return false 129 | } 130 | if slices.Contains(g.BlockedCountries, country) { 131 | return false 132 | } 133 | if slices.Contains(g.AllowedCountries, country) { 134 | return true 135 | } 136 | 137 | // if exclusion is provided, the rest will be allowed 138 | if len(g.BlockedCountries) > 0 { 139 | return true 140 | } 141 | 142 | // othewise fail 143 | return false 144 | } 145 | 146 | // implement the ACL interface 147 | func (g geoIP) Decide(c *ConnInfo) error { 148 | g.logger.Debug().Any("conn", c).Msg("deciding on geoip acl") 149 | // in checkGeoIPSkip, false is reject 150 | if !g.checkGeoIPSkip(c.SrcIP) { 151 | g.logger.Info().Msgf("rejecting connection from ip %s", c.SrcIP) 152 | c.Decision = Reject 153 | } 154 | g.logger.Debug().Any("conn", c).Msg("decided on geoip acl") 155 | return nil 156 | } 157 | func (g geoIP) Name() string { 158 | return "geoip" 159 | } 160 | func (g geoIP) Priority() uint { 161 | return g.priority 162 | } 163 | 164 | func (g *geoIP) ConfigAndStart(logger *zerolog.Logger, c *koanf.Koanf) error { 165 | c = c.Cut(fmt.Sprintf("acl.%s", g.Name())) 166 | g.logger = logger 167 | g.Path = c.String("path") 168 | g.priority = uint(c.Int("priority")) 169 | g.AllowedCountries = toLowerSlice(c.Strings("allowed")) 170 | g.BlockedCountries = toLowerSlice(c.Strings("blocked")) 171 | g.Refresh = c.Duration("refresh_interval") 172 | go g.initializeGeoIP() 173 | return nil 174 | } 175 | 176 | // make the geoIP available at import time 177 | func init() { 178 | availableACLs = append(availableACLs, &geoIP{}) 179 | } 180 | -------------------------------------------------------------------------------- /pkg/acl/override.go: -------------------------------------------------------------------------------- 1 | package acl 2 | 3 | import ( 4 | "fmt" 5 | "net" 6 | "os" 7 | "path/filepath" 8 | "strings" 9 | 10 | "github.com/knadh/koanf" 11 | dohserver "github.com/mosajjal/sniproxy/v2/pkg/doh" 12 | "github.com/rs/zerolog" 13 | "inet.af/tcpproxy" 14 | ) 15 | 16 | // override ACL. This ACL is used to override the destination IP to not be the one resolved by the upstream DNS or the proxy itself, rather a custom IP and port 17 | // if the destination is HTTP, it uses tls_cert and tls_key certificate to terminate the original connection. 18 | type override struct { 19 | priority uint 20 | rules map[string]string 21 | doh *dohserver.Server 22 | dohPort int 23 | tcpproxy *tcpproxy.Proxy 24 | tcpproxyport int 25 | tlsCert string 26 | tlsKey string 27 | logger *zerolog.Logger 28 | } 29 | 30 | // GetFreePort returns a random open port 31 | func GetFreePort() (int, error) { 32 | addr, err := net.ResolveTCPAddr("tcp", "localhost:0") 33 | if err != nil { 34 | return 0, err 35 | } 36 | 37 | l, err := net.ListenTCP("tcp", addr) 38 | if err != nil { 39 | return GetFreePort() 40 | } 41 | defer l.Close() 42 | return l.Addr().(*net.TCPAddr).Port, nil 43 | } 44 | 45 | // tcpproxy listens on a random port on localhost 46 | func (o *override) startProxy() { 47 | o.tcpproxy = new(tcpproxy.Proxy) 48 | var err error 49 | o.tcpproxyport, err = GetFreePort() 50 | if err != nil { 51 | o.logger.Error().Msgf("failed to get a free port for tcpproxy: %s", err) 52 | return 53 | } 54 | for k, v := range o.rules { 55 | o.logger.Info().Msgf("adding overide rule %s -> %s", k, v) 56 | // TODO: create a regex matcher for SNIRoute 57 | o.tcpproxy.AddSNIRoute(fmt.Sprintf("127.0.0.1:%d", o.tcpproxyport), k, tcpproxy.To(v)) 58 | } 59 | o.logger.Info().Msgf("starting tcpproxy on port %d", o.tcpproxyport) 60 | o.tcpproxy.Run() 61 | } 62 | 63 | func (o override) Decide(c *ConnInfo) error { 64 | o.logger.Debug().Any("conn", c).Msg("deciding on override acl") 65 | domain := strings.TrimSuffix(c.Domain, ".") 66 | for k := range o.rules { 67 | if strings.TrimSuffix(k, ".") == domain { 68 | c.Decision = Override 69 | a, _ := net.ResolveTCPAddr("tcp", fmt.Sprintf("127.0.0.1:%d", o.tcpproxyport)) 70 | c.DstIP = *a 71 | break 72 | } 73 | } 74 | o.logger.Debug().Any("conn", c).Msg("decided on override acl") 75 | return nil 76 | } 77 | 78 | func (o override) Name() string { 79 | return "override" 80 | } 81 | func (o override) Priority() uint { 82 | return o.priority 83 | } 84 | 85 | func (o *override) ConfigAndStart(logger *zerolog.Logger, c *koanf.Koanf) error { 86 | DNSBind := c.String("general.bind_dns_over_udp") 87 | c = c.Cut(fmt.Sprintf("acl.%s", o.Name())) 88 | tmpRules := c.StringMap("rules") 89 | o.logger = logger 90 | o.priority = uint(c.Int("priority")) 91 | o.tlsCert = c.String("tls_cert") 92 | o.tlsKey = c.String("tls_key") 93 | if c.String("doh_sni") != "" { 94 | dohSNI := c.String("doh_sni") 95 | var err error 96 | o.dohPort, err = GetFreePort() 97 | if err != nil { 98 | return err 99 | } 100 | dohConfig := dohserver.NewDefaultConfig() 101 | dohConfig.Listen = []string{fmt.Sprintf("127.0.0.1:%d", o.dohPort)} 102 | if o.tlsCert == "" || o.tlsKey == "" { 103 | _, _, err := dohserver.GenerateSelfSignedCertKey(dohSNI, nil, nil, os.TempDir()) 104 | o.logger.Info().Msg("certificate was not provided, generating a self signed cert in temp directory") 105 | if err != nil { 106 | o.logger.Error().Msgf("error while generating self-signed cert: %s", err) 107 | } 108 | o.tlsCert = filepath.Join(os.TempDir(), dohSNI+".crt") 109 | o.tlsKey = filepath.Join(os.TempDir(), dohSNI+".key") 110 | } 111 | dohConfig.Cert = o.tlsCert 112 | dohConfig.Key = o.tlsKey 113 | dohConfig.Upstream = []string{fmt.Sprintf("udp:%s", DNSBind)} 114 | dohS, err := dohserver.NewServer(dohConfig) 115 | if err != nil { 116 | return err 117 | } 118 | go dohS.Start() 119 | // append a rule to the rules map to redirect the DoH SNI to DoH 120 | tmpRules[dohSNI] = fmt.Sprintf("127.0.0.1:%d", o.dohPort) 121 | } 122 | o.rules = tmpRules 123 | 124 | go o.startProxy() 125 | return nil 126 | } 127 | 128 | // make domain available to the ACL system at import time 129 | func init() { 130 | availableACLs = append(availableACLs, &override{}) 131 | } 132 | -------------------------------------------------------------------------------- /pkg/conf.go: -------------------------------------------------------------------------------- 1 | package sniproxy 2 | 3 | import ( 4 | "fmt" 5 | "net/netip" 6 | "net/url" 7 | "strconv" 8 | "strings" 9 | "time" 10 | 11 | "github.com/mosajjal/sniproxy/v2/pkg/acl" 12 | "github.com/rcrowley/go-metrics" 13 | "github.com/rs/zerolog" 14 | "github.com/txthinking/socks5" 15 | "golang.org/x/net/proxy" 16 | ) 17 | 18 | // Config is the main runtime configuration for the proxy 19 | type Config struct { 20 | PublicIPv4 string `yaml:"public_ipv4"` 21 | PublicIPv6 string `yaml:"public_ipv6"` 22 | UpstreamDNS string `yaml:"upstream_dns"` 23 | UpstreamDNSOverSocks5 bool `yaml:"upstream_dns_over_socks5"` 24 | UpstreamSOCKS5 string `yaml:"upstream_socks5"` 25 | BindDNSOverUDP string `yaml:"bind_dns_over_udp"` 26 | BindDNSOverTCP string `yaml:"bind_dns_over_tcp"` 27 | BindDNSOverTLS string `yaml:"bind_dns_over_tls"` 28 | BindDNSOverQuic string `yaml:"bind_dns_over_quic"` 29 | TLSCert string `yaml:"tls_cert"` 30 | TLSKey string `yaml:"tls_key"` 31 | BindHTTP string `yaml:"bind_http"` 32 | BindHTTPAdditional []string `yaml:"bind_http_additional"` 33 | BindHTTPListeners []string `yaml:"-"` // compiled list of bind_http and bind_http_additional listen addresses 34 | BindHTTPS string `yaml:"bind_https"` 35 | BindHTTPSAdditional []string `yaml:"bind_https_additional"` 36 | BindHTTPSListeners []string `yaml:"-"` // compiled list of bind_https and bind_https_additional listen addresses 37 | Interface string `yaml:"interface"` 38 | BindPrometheus string `yaml:"bind_prometheus"` 39 | AllowConnToLocal bool `yaml:"allow_conn_to_local"` 40 | 41 | ACL []acl.ACL `yaml:"-"` 42 | 43 | DNSClient DNSClient `yaml:"-"` 44 | Dialer proxy.Dialer `yaml:"-"` 45 | // list of interface source IPs; used to rotate source IPs when initializing connections 46 | SourceAddr []netip.Addr `yaml:"-"` 47 | PreferredVersion string `yaml:"preferred_version"` // ipv4 (or 4), ipv6 (or 6), ipv4only, ipv6only, any. empty (or 0) means any. 48 | 49 | // metrics 50 | RecievedHTTP metrics.Counter `yaml:"-"` 51 | ProxiedHTTP metrics.Counter `yaml:"-"` 52 | RecievedHTTPS metrics.Counter `yaml:"-"` 53 | ProxiedHTTPS metrics.Counter `yaml:"-"` 54 | RecievedDNS metrics.Counter `yaml:"-"` 55 | ProxiedDNS metrics.Counter `yaml:"-"` 56 | } 57 | 58 | const ( 59 | // DNSTimeout is the default timeout for DNS queries 60 | DNSTimeout = 10 * time.Second 61 | // HTTPReadTimeout is the default timeout for HTTP requests 62 | HTTPReadTimeout = 10 * time.Second 63 | // HTTPWriteTimeout is the default timeout for HTTP responses 64 | HTTPWriteTimeout = 10 * time.Second 65 | ) 66 | 67 | // below are some functions to help populating some config fields based on other config fields 68 | 69 | // SetDialer sets up a TCP/UDP Dialer based on the proxy settings provided 70 | // an error in this function means the application cannot continue 71 | func (c *Config) SetDialer(logger zerolog.Logger) error { 72 | // sniproxy has the ability to use a SOCKS5 proxy for upstream connections 73 | // optionally, it can use the same SOCKS5 proxy for DNS queries 74 | if c.UpstreamSOCKS5 != "" { 75 | uri, err := url.Parse(c.UpstreamSOCKS5) 76 | if err != nil { 77 | // non-fatal error message 78 | logger.Error().Msg(err.Error()) 79 | } 80 | if uri.Scheme != "socks5" { 81 | return fmt.Errorf("only SOCKS5 is supported") 82 | } 83 | 84 | logger.Info().Msgf("Using an upstream SOCKS5 proxy: %s", uri.Host) 85 | socksAuth := new(proxy.Auth) 86 | socksAuth.User = uri.User.Username() 87 | socksAuth.Password, _ = uri.User.Password() 88 | c.Dialer, err = socks5.NewClient(uri.Host, socksAuth.User, socksAuth.Password, 60, 60) 89 | if err != nil { 90 | // non-fatal error message 91 | logger.Error().Msg(err.Error()) 92 | } 93 | } else { 94 | c.Dialer = proxy.Direct 95 | } 96 | return nil 97 | } 98 | 99 | // SetDNSClient sets up a DNS client based on the proxy settings provided 100 | // an error in this function means the application cannot continue 101 | func (c *Config) SetDNSClient(logger zerolog.Logger) error { 102 | 103 | // dnsProxy is a proxy used for upstream DNS connection. 104 | var dnsProxy string 105 | var dnsClient *DNSClient 106 | // if upstream socks5 is not provided or upstream dns over socks5 is disabled, disable socks5 for dns 107 | if c.UpstreamSOCKS5 != "" && !c.UpstreamDNSOverSocks5 { 108 | logger.Debug().Msg("disabling socks5 for dns because either upstream socks5 is not provided or upstream dns over socks5 is disabled") 109 | dnsProxy = "" 110 | } else { 111 | dnsProxy = c.UpstreamSOCKS5 112 | } 113 | var err error 114 | dnsClient, err = NewDNSClient(c, c.UpstreamDNS, true, dnsProxy) 115 | if err != nil { 116 | logger.Error().Msgf("error setting up dns client with socks5 proxy, falling back to direct DNS client: %v", err) 117 | dnsClient, err = NewDNSClient(c, c.UpstreamDNS, false, "") 118 | if err != nil { 119 | return fmt.Errorf("error setting up dns client: %v", err) 120 | } 121 | } 122 | c.DNSClient = *dnsClient 123 | return nil 124 | } 125 | 126 | // parseRanges parses a range of ports or a single port. It returns a list of ports 127 | func parseRanges(portRange ...string) ([]int, error) { 128 | var ports []int 129 | 130 | for _, portRange := range portRange { 131 | 132 | if strings.Index(portRange, "-") == -1 { 133 | port, err := strconv.Atoi(portRange) 134 | if err != nil { 135 | return nil, fmt.Errorf("error parsing port: %v", err) 136 | } 137 | ports = append(ports, port) 138 | } else { 139 | num1Str := strings.Split(portRange, "-")[0] 140 | num2Str := strings.Split(portRange, "-")[1] 141 | // convert both numbers to integers 142 | 143 | num1, err := strconv.Atoi(num1Str) 144 | if err != nil { 145 | return nil, fmt.Errorf("error parsing port range: %v", err) 146 | } 147 | num2, err := strconv.Atoi(num2Str) 148 | if err != nil { 149 | return nil, fmt.Errorf("error parsing port range: %v", err) 150 | } 151 | for i := num1; i <= num2; i++ { 152 | ports = append(ports, i) 153 | } 154 | } 155 | } 156 | return ports, nil 157 | } 158 | 159 | // parseBinders parses a bind address and a list of additional ports or port ranges 160 | func parseBinders(bind string, additional []string) ([]string, error) { 161 | // get the bind address from bind 162 | bindAddPort, err := netip.ParseAddrPort(bind) 163 | if err != nil { 164 | return nil, fmt.Errorf("error parsing bind address: %v", err) 165 | } 166 | bindAddresses := []string{bindAddPort.String()} 167 | 168 | // now all the ranges must be parsed, and each of them converted into a bind address and added to the list 169 | portRange, err := parseRanges(additional...) 170 | if err != nil { 171 | return nil, fmt.Errorf("error parsing bind address range: %v", err) 172 | } 173 | for _, port := range portRange { 174 | bindAddresses = append(bindAddresses, fmt.Sprintf("%s:%d", bindAddPort.Addr(), port)) 175 | } 176 | return bindAddresses, nil 177 | } 178 | 179 | // SetBindHTTPListeners sets up a list of bind addresses for HTTP 180 | // it gets the bind address from bind_http as 0.0.0.0:80 format 181 | // and the additional bind addresses from bind_http_additional as a list of ports or port ranges 182 | // such as 8080, 8081-8083, 8085 183 | // when this function is called, it will compile the list of bind addresses and store it in BindHTTPListeners 184 | func (c *Config) SetBindHTTPListeners(_ zerolog.Logger) error { 185 | bindAddresses, err := parseBinders(c.BindHTTP, c.BindHTTPAdditional) 186 | if err != nil { 187 | return fmt.Errorf("error parsing bind addresses for HTTP: %v", err) 188 | } 189 | c.BindHTTPListeners = bindAddresses 190 | return nil 191 | } 192 | 193 | // SetBindHTTPSListeners sets up a list of bind addresses for HTTPS 194 | func (c *Config) SetBindHTTPSListeners(_ zerolog.Logger) error { 195 | bindAddresses, err := parseBinders(c.BindHTTPS, c.BindHTTPSAdditional) 196 | if err != nil { 197 | return fmt.Errorf("error parsing bind addresses for HTTPS: %v", err) 198 | } 199 | c.BindHTTPSListeners = bindAddresses 200 | return nil 201 | } 202 | -------------------------------------------------------------------------------- /pkg/dns.go: -------------------------------------------------------------------------------- 1 | package sniproxy 2 | 3 | import ( 4 | "crypto/tls" 5 | "fmt" 6 | "math/rand" 7 | "net" 8 | "net/netip" 9 | "net/url" 10 | "strings" 11 | "sync" 12 | 13 | rdns "github.com/folbricht/routedns" 14 | 15 | doqserver "github.com/mosajjal/doqd/pkg/server" 16 | "github.com/mosajjal/sniproxy/v2/pkg/acl" 17 | "github.com/rs/zerolog" 18 | "github.com/txthinking/socks5" 19 | "golang.org/x/net/proxy" 20 | 21 | "github.com/miekg/dns" 22 | ) 23 | 24 | // DNSClient is a wrapper around the DNS client 25 | type DNSClient struct { 26 | rdns.Resolver 27 | C *Config 28 | } 29 | 30 | var dnsLock sync.RWMutex 31 | 32 | // findBootstrapIP tries to resolve well-known DNS resolvers 33 | // to their IP addresses 34 | // 35 | // dns.quad9.net -> 9.9.9.9, 2620:fe::9 36 | // one.one.one.one -> 1.1.1.1, 2606:4700:4700::1111 37 | // dns.google -> 8.8.8.8, 2001:4860:4860::8888 38 | func findBootstrapIP(fqdn string, version int) string { 39 | wellKnownDomains := map[string]map[int]string{ 40 | "dns.quad9.net": {4: "9.9.9.9", 6: "2620:fe::9"}, 41 | "one.one.one.one": {4: "1.1.1.1", 6: "2606:4700:4700::1111"}, 42 | "dns.google": {4: "8.8.8.8", 6: "2001:4860:4860::8888"}, 43 | } 44 | if version != 4 && version != 6 { 45 | return "" 46 | } 47 | if ips, ok := wellKnownDomains[fqdn]; !ok { 48 | return "" 49 | } else { 50 | return ips[version] 51 | } 52 | } 53 | 54 | // pickSrcAddr picks a random source address from the list of configured source addresses. 55 | // version specifies the IP version to pick, 4 or 6. If 0, any version is picked. 56 | func (c *Config) pickSrcAddr(version string) net.IP { 57 | if len(c.SourceAddr) == 0 { 58 | return nil 59 | } 60 | // shuffle the list of source addresses 61 | // TODO: potentially a better way to do this 62 | for i := range c.SourceAddr { 63 | j := rand.Intn(i + 1) 64 | c.SourceAddr[i], c.SourceAddr[j] = c.SourceAddr[j], c.SourceAddr[i] 65 | } 66 | switch version { 67 | case "ipv4only": 68 | for _, ip := range c.SourceAddr { 69 | if ip.Is4() { 70 | return ip.AsSlice() 71 | } 72 | } 73 | case "ipv6only": 74 | for _, ip := range c.SourceAddr { 75 | if ip.Is6() { 76 | return ip.AsSlice() 77 | } 78 | } 79 | case "ipv4", "4", "0": 80 | for _, ip := range c.SourceAddr { 81 | if ip.Is4() { 82 | return ip.AsSlice() 83 | } 84 | if ip.Is6() { 85 | return ip.AsSlice() 86 | } 87 | } 88 | case "ipv6", "6": 89 | for _, ip := range c.SourceAddr { 90 | if ip.Is6() { 91 | return ip.AsSlice() 92 | } 93 | if ip.Is4() { 94 | return ip.AsSlice() 95 | } 96 | } 97 | } 98 | return nil 99 | } 100 | 101 | // PerformExternalAQuery performs an external DNS query for the given domain name. 102 | func (dnsc *DNSClient) PerformExternalAQuery(fqdn string, QType uint16) ([]dns.RR, error) { 103 | if !strings.HasSuffix(fqdn, ".") { 104 | fqdn = fqdn + "." 105 | } 106 | 107 | msg := dns.Msg{} 108 | msg.RecursionDesired = true 109 | msg.SetQuestion(fqdn, QType) 110 | msg.SetEdns0(1232, true) 111 | dnsLock.Lock() 112 | if dnsc == nil { 113 | return nil, fmt.Errorf("dns client is not initialised") 114 | } 115 | res, err := dnsc.Resolve(&msg, rdns.ClientInfo{}) 116 | dnsLock.Unlock() 117 | if res == nil { 118 | return nil, err 119 | } 120 | return res.Answer, err 121 | } 122 | 123 | func processQuestion(c *Config, l zerolog.Logger, q dns.Question, decision acl.Decision) ([]dns.RR, error) { 124 | c.RecievedDNS.Inc(1) 125 | // Check to see if we should respond with our own IP 126 | switch decision { 127 | 128 | // Return the public IP. 129 | case acl.ProxyIP, acl.Override, acl.Accept: // TODO: accept should be here? 130 | c.ProxiedDNS.Inc(1) 131 | l.Info().Msgf("returned sniproxy address for domain %s", q.Name) 132 | 133 | if q.Qtype == dns.TypeA { 134 | rr, err := dns.NewRR(fmt.Sprintf("%s A %s", q.Name, c.PublicIPv4)) 135 | return []dns.RR{rr}, err 136 | } 137 | if q.Qtype == dns.TypeAAAA { 138 | if c.PublicIPv6 != "" { 139 | rr, err := dns.NewRR(fmt.Sprintf("%s AAAA %s", q.Name, c.PublicIPv6)) 140 | return []dns.RR{rr}, err 141 | } 142 | // return an empty response if we don't have an IPv6 address 143 | return []dns.RR{}, nil 144 | } 145 | 146 | // return empty response for rejected ACL 147 | case acl.Reject: 148 | // drop the request 149 | l.Debug().Msgf("rejected request for domain %s", q.Name) 150 | return []dns.RR{}, nil 151 | 152 | // Otherwise do an upstream query and use that answer. 153 | default: 154 | l.Debug().Msgf("perform external query for domain %s", q.Name) 155 | resp, err := c.DNSClient.PerformExternalAQuery(q.Name, q.Qtype) 156 | if err != nil { 157 | return nil, err 158 | } 159 | l.Info().Msgf("returned origin address for fqdn %s", q.Name) 160 | return resp, nil 161 | } 162 | return []dns.RR{}, nil 163 | } 164 | 165 | // lookupDomain looks up a domain name and returns the IP address. 166 | // version specifies the IP version to lookup, 4 or 6. If 0, any version is picked. currently 0 is ipv4 with ipv6 fallback 167 | // options are: ipv4 (or 4) and ipv6 (or 6), ipv4only and ipv6only 168 | func (dnsc DNSClient) lookupDomain(domain string, version string) (netip.Addr, error) { 169 | 170 | switch version { 171 | case "ipv4only": 172 | return dnsc.lookupDomain4(domain) 173 | case "ipv6only": 174 | return dnsc.lookupDomain6(domain) 175 | case "ipv4", "4", "0", "": 176 | // try with ipv4, if there's any error, try with ipv6 177 | ip, err := dnsc.lookupDomain4(domain) 178 | if err != nil { 179 | return dnsc.lookupDomain6(domain) 180 | } 181 | return ip, nil 182 | case "ipv6", "6": 183 | // try with ipv6, if there's any error, try with ipv4 184 | ip, err := dnsc.lookupDomain6(domain) 185 | if err != nil { 186 | return dnsc.lookupDomain4(domain) 187 | } 188 | return ip, nil 189 | } 190 | return netip.IPv4Unspecified(), fmt.Errorf("invalid version") 191 | } 192 | 193 | func (dnsc DNSClient) lookupDomain4(domain string) (netip.Addr, error) { 194 | if !strings.HasSuffix(domain, ".") { 195 | domain = domain + "." 196 | } 197 | rAddrDNS, err := dnsc.PerformExternalAQuery(domain, dns.TypeA) 198 | if err != nil { 199 | return netip.IPv4Unspecified(), err 200 | } 201 | if len(rAddrDNS) > 0 { 202 | if rAddrDNS[0].Header().Rrtype == dns.TypeCNAME { 203 | return dnsc.lookupDomain4(rAddrDNS[0].(*dns.CNAME).Target) 204 | } 205 | if rAddrDNS[0].Header().Rrtype == dns.TypeA { 206 | return netip.AddrFrom4([4]byte(rAddrDNS[0].(*dns.A).A.To4())), nil 207 | } 208 | } else { 209 | return netip.IPv4Unspecified(), fmt.Errorf("[DNS] Empty DNS response for %s", domain) 210 | } 211 | return netip.IPv4Unspecified(), fmt.Errorf("[DNS] Unknown type %s", dns.TypeToString[rAddrDNS[0].Header().Rrtype]) 212 | } 213 | 214 | func (dnsc DNSClient) lookupDomain6(domain string) (netip.Addr, error) { 215 | if !strings.HasSuffix(domain, ".") { 216 | domain = domain + "." 217 | } 218 | rAddrDNS, err := dnsc.PerformExternalAQuery(domain, dns.TypeAAAA) 219 | if err != nil { 220 | return netip.IPv6Unspecified(), err 221 | } 222 | if len(rAddrDNS) > 0 { 223 | if rAddrDNS[0].Header().Header().Rrtype == dns.TypeCNAME { 224 | return dnsc.lookupDomain6(rAddrDNS[0].(*dns.CNAME).Target) 225 | } 226 | if rAddrDNS[0].Header().Rrtype == dns.TypeAAAA { 227 | return netip.AddrFrom16([16]byte(rAddrDNS[0].(*dns.AAAA).AAAA.To16())), nil 228 | } 229 | } else { 230 | return netip.IPv6Unspecified(), fmt.Errorf("[DNS] Empty DNS response for %s", domain) 231 | } 232 | return netip.IPv6Unspecified(), fmt.Errorf("[DNS] Unknown type %s", dns.TypeToString[rAddrDNS[0].Header().Rrtype]) 233 | } 234 | 235 | func handleDNS(c *Config, l zerolog.Logger) dns.HandlerFunc { 236 | return func(w dns.ResponseWriter, r *dns.Msg) { 237 | m := new(dns.Msg) 238 | m.SetReply(r) 239 | m.Compress = false 240 | 241 | if r.Opcode != dns.OpcodeQuery { 242 | m.SetRcode(r, dns.RcodeNotImplemented) 243 | w.WriteMsg(m) 244 | return 245 | } 246 | 247 | for _, q := range m.Question { 248 | connInfo := acl.ConnInfo{ 249 | SrcIP: w.RemoteAddr(), 250 | Domain: q.Name, 251 | } 252 | acl.MakeDecision(&connInfo, c.ACL) 253 | answers, err := processQuestion(c, l, q, connInfo.Decision) 254 | if err != nil { 255 | continue 256 | } 257 | m.Answer = append(m.Answer, answers...) 258 | } 259 | 260 | w.WriteMsg(m) 261 | } 262 | } 263 | 264 | // RunDNS starts DNS servers based on the provided configuration. 265 | func RunDNS(c *Config, l zerolog.Logger) { 266 | l = l.With().Str("service", "dns").Logger() 267 | dns.HandleFunc(".", handleDNS(c, l)) 268 | // start DNS UDP serverUdp 269 | if c.BindDNSOverUDP != "" { 270 | go func() { 271 | serverUDP := &dns.Server{Addr: c.BindDNSOverUDP, Net: "udp"} 272 | l.Info().Msgf("started udp dns on %s", c.BindDNSOverUDP) 273 | err := serverUDP.ListenAndServe() 274 | defer serverUDP.Shutdown() 275 | if err != nil { 276 | l.Error().Msgf("error starting udp dns server: %s", err) 277 | l.Info().Msgf("failed to start server: %s\nyou can run the following command to pinpoint which process is listening on your bind\nsudo ss -pltun", c.BindDNSOverUDP) 278 | panic(2) 279 | } 280 | }() 281 | } 282 | // start DNS UDP serverTcp 283 | if c.BindDNSOverTCP != "" { 284 | go func() { 285 | serverTCP := &dns.Server{Addr: c.BindDNSOverTCP, Net: "tcp"} 286 | l.Info().Msgf("started tcp dns on %s", c.BindDNSOverTCP) 287 | err := serverTCP.ListenAndServe() 288 | defer serverTCP.Shutdown() 289 | if err != nil { 290 | l.Error().Msgf("failed to start server %s", err) 291 | l.Info().Msgf("failed to start server: %s\nyou can run the following command to pinpoint which process is listening on your bind\nsudo ss -pltun", c.BindDNSOverUDP) 292 | } 293 | }() 294 | } 295 | 296 | // start DNS UDP serverTls 297 | if c.BindDNSOverTLS != "" { 298 | go func() { 299 | crt, err := tls.LoadX509KeyPair(c.TLSCert, c.TLSKey) 300 | if err != nil { 301 | l.Error().Msg(err.Error()) 302 | panic(2) 303 | 304 | } 305 | tlsConfig := &tls.Config{} 306 | tlsConfig.Certificates = []tls.Certificate{crt} 307 | 308 | serverTLS := &dns.Server{Addr: c.BindDNSOverTLS, Net: "tcp-tls", TLSConfig: tlsConfig} 309 | l.Info().Msgf("started dot dns on %s", c.BindDNSOverTLS) 310 | err = serverTLS.ListenAndServe() 311 | defer serverTLS.Shutdown() 312 | if err != nil { 313 | l.Error().Msg(err.Error()) 314 | } 315 | }() 316 | } 317 | 318 | if c.BindDNSOverQuic != "" { 319 | 320 | crt, err := tls.LoadX509KeyPair(c.TLSCert, c.TLSKey) 321 | if err != nil { 322 | l.Error().Msg(err.Error()) 323 | } 324 | 325 | // Create the QUIC listener 326 | doqConf := doqserver.Config{ 327 | ListenAddr: c.BindDNSOverQuic, 328 | Cert: crt, 329 | Upstream: c.BindDNSOverUDP, 330 | TLSCompat: true, 331 | Debug: l.GetLevel() == zerolog.DebugLevel, 332 | } 333 | doqServer, err := doqserver.New(doqConf) 334 | if err != nil { 335 | l.Error().Msg(err.Error()) 336 | } 337 | 338 | // Accept QUIC connections 339 | l.Info().Msgf("starting quic listener %s", c.BindDNSOverQuic) 340 | go doqServer.Listen() 341 | 342 | } 343 | } 344 | 345 | func getDialerFromProxyURL(proxyURL *url.URL) (*rdns.Dialer, error) { 346 | var dialer rdns.Dialer 347 | // by default dialer is direct 348 | dialer = &net.Dialer{} 349 | if proxyURL != nil && proxyURL.Host != "" { 350 | // create a net dialer with proxy 351 | auth := new(proxy.Auth) 352 | if proxyURL.User != nil { 353 | auth.User = proxyURL.User.Username() 354 | if p, ok := proxyURL.User.Password(); ok { 355 | auth.Password = p 356 | } else { 357 | auth.Password = "" 358 | } 359 | } 360 | c, err := socks5.NewClient(proxyURL.Host, auth.User, auth.Password, 0, 5) // 0 and 5 are borrowed from routedns pr 361 | if err != nil { 362 | return nil, err 363 | } 364 | dialer = c 365 | } 366 | return &dialer, nil 367 | } 368 | 369 | /* 370 | NewDNSClient creates a DNS Client by parsing a URI and returning the appropriate client for it 371 | URI string could look like below: 372 | - udp://1.1.1.1:53 373 | - udp6://[2606:4700:4700::1111]:53 374 | - tcp://9.9.9.9:5353 375 | - https://dns.adguard.com 376 | - quic://dns.adguard.com:8853 377 | - tcp-tls://dns.adguard.com:853 378 | */ 379 | func NewDNSClient(C *Config, uri string, skipVerify bool, proxy string) (*DNSClient, error) { 380 | parsedURL, err := url.Parse(uri) 381 | if err != nil { 382 | return nil, err 383 | } 384 | 385 | var dialer *rdns.Dialer 386 | proxyURL, err := url.Parse(proxy) 387 | if err != nil { 388 | return nil, err 389 | } 390 | dialer, err = getDialerFromProxyURL(proxyURL) 391 | if err != nil { 392 | return nil, err 393 | } 394 | 395 | switch parsedURL.Scheme { 396 | case "udp", "udp6": 397 | var host, port string 398 | // if port doesn't exist, use default port 399 | if host, port, err = net.SplitHostPort(parsedURL.Host); err != nil { 400 | host = parsedURL.Host 401 | port = "53" 402 | } 403 | Address := rdns.AddressWithDefault(host, port) 404 | 405 | var ldarr net.IP 406 | if parsedURL.Scheme == "udp6" { 407 | ldarr = C.pickSrcAddr("ipv6only") 408 | } else { 409 | ldarr = C.pickSrcAddr("ipv4only") 410 | } 411 | 412 | opt := rdns.DNSClientOptions{ 413 | LocalAddr: ldarr, 414 | UDPSize: 1300, 415 | Dialer: *dialer, 416 | QueryTimeout: DNSTimeout, 417 | } 418 | id, err := rdns.NewDNSClient("id", Address, "udp", opt) 419 | if err != nil { 420 | return nil, err 421 | } 422 | return &DNSClient{id, C}, nil 423 | case "tcp", "tcp6": 424 | var host, port string 425 | // if port doesn't exist, use default port 426 | if host, port, err = net.SplitHostPort(parsedURL.Host); err != nil { 427 | host = parsedURL.Host 428 | port = "53" 429 | } 430 | 431 | var ldarr net.IP 432 | if parsedURL.Scheme == "tcp6" { 433 | ldarr = C.pickSrcAddr("ipv6only") 434 | } else { 435 | ldarr = C.pickSrcAddr("ipv4only") 436 | } 437 | 438 | Address := rdns.AddressWithDefault(host, port) 439 | opt := rdns.DNSClientOptions{ 440 | LocalAddr: ldarr, 441 | UDPSize: 1300, 442 | Dialer: *dialer, 443 | } 444 | id, err := rdns.NewDNSClient("id", Address, "tcp", opt) 445 | if err != nil { 446 | return nil, err 447 | } 448 | return &DNSClient{id, C}, nil 449 | case "tls", "tls6", "tcp-tls", "tcp-tls6": 450 | tlsConfig, err := rdns.TLSClientConfig("", "", "", parsedURL.Host) 451 | if err != nil { 452 | return nil, err 453 | } 454 | var ldarr net.IP 455 | bootstrapAddr := findBootstrapIP(parsedURL.Host, 4) 456 | if parsedURL.Scheme == "tls6" || parsedURL.Scheme == "tcp-tls6" { 457 | ldarr = C.pickSrcAddr("ipv6only") 458 | bootstrapAddr = findBootstrapIP(parsedURL.Host, 6) 459 | } else { 460 | ldarr = C.pickSrcAddr("ipv4only") 461 | } 462 | 463 | opt := rdns.DoTClientOptions{ 464 | TLSConfig: tlsConfig, 465 | BootstrapAddr: bootstrapAddr, 466 | LocalAddr: ldarr, 467 | Dialer: *dialer, 468 | } 469 | id, err := rdns.NewDoTClient("id", parsedURL.Host, opt) 470 | if err != nil { 471 | return nil, err 472 | } 473 | return &DNSClient{id, C}, nil 474 | case "https": 475 | tlsConfig := &tls.Config{ 476 | InsecureSkipVerify: skipVerify, 477 | ServerName: strings.Split(parsedURL.Host, ":")[0], 478 | } 479 | 480 | transport := "tcp" 481 | opt := rdns.DoHClientOptions{ 482 | Method: "POST", // TODO: support anything other than POST 483 | TLSConfig: tlsConfig, 484 | BootstrapAddr: findBootstrapIP(parsedURL.Host, 4), 485 | Transport: transport, 486 | LocalAddr: C.pickSrcAddr("ipv4only"), //TODO:support IPv6 487 | Dialer: *dialer, 488 | } 489 | id, err := rdns.NewDoHClient("id", parsedURL.String(), opt) 490 | if err != nil { 491 | return nil, err 492 | } 493 | return &DNSClient{id, C}, nil 494 | 495 | case "quic": 496 | tlsConfig := &tls.Config{ 497 | InsecureSkipVerify: skipVerify, 498 | ServerName: strings.Split(parsedURL.Host, ":")[0], 499 | } 500 | 501 | opt := rdns.DoQClientOptions{ 502 | TLSConfig: tlsConfig, 503 | LocalAddr: C.pickSrcAddr("ipv4only"), //TODO:support IPv6 504 | // Dialer: *dialer, // BUG: not yet supported 505 | } 506 | id, err := rdns.NewDoQClient("id", parsedURL.Host, opt) 507 | if err != nil { 508 | return nil, err 509 | } 510 | return &DNSClient{id, C}, nil 511 | } 512 | return nil, fmt.Errorf("Can't understand the URL") 513 | } 514 | -------------------------------------------------------------------------------- /pkg/dns_test.go: -------------------------------------------------------------------------------- 1 | package sniproxy 2 | 3 | import ( 4 | "net" 5 | "testing" 6 | ) 7 | 8 | func TestDNSClient_lookupDomain4(t *testing.T) { 9 | c := Config{ 10 | UpstreamDNS: "tcp://1.1.1.1:53", 11 | } 12 | dnsc, err := NewDNSClient(&c, c.UpstreamDNS, true, "") 13 | if err != nil { 14 | t.Errorf("failed to set up DNS client") 15 | } 16 | tests := []struct { 17 | client *DNSClient 18 | name string 19 | domain string 20 | want []net.IP 21 | wantErr bool 22 | }{ 23 | {client: dnsc, name: "test1", domain: "ident.me", want: []net.IP{net.IPv4(49, 12, 234, 183)}, wantErr: false}, 24 | {client: dnsc, name: "test2", domain: "one.one.one.one", want: []net.IP{net.IPv4(1, 1, 1, 1), net.IPv4(1, 0, 0, 1)}, wantErr: false}, 25 | } 26 | for _, tt := range tests { 27 | t.Run(tt.name, func(t *testing.T) { 28 | gotTmp, err := tt.client.lookupDomain4(tt.domain) 29 | got := net.IP(gotTmp.AsSlice()) 30 | if (err != nil) != tt.wantErr { 31 | t.Errorf("DNSClient.lookupDomain4() error = %v, wantErr %v", err, tt.wantErr) 32 | return 33 | } 34 | // check if the returned IP is in the list of expected IPs 35 | found := false 36 | for _, w := range tt.want { 37 | if got.Equal(w) { 38 | found = true 39 | break 40 | } 41 | } 42 | if !found { 43 | t.Errorf("DNSClient.lookupDomain4() = %v, want %v", got, tt.want) 44 | } 45 | }) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /pkg/doc.go: -------------------------------------------------------------------------------- 1 | /* 2 | Package sniproxy is a simple SNI proxy server that allows you to serve multiple SSL-enabled websites from a single IP address. 3 | 4 | Continuation of [byosh] and [SimpleSNIProxy] projects. 5 | 6 | # pre-requisites 7 | 8 | To ensure that Sniproxy works correctly, it's important to have ports 80, 443, and 53 open. 9 | However, on Ubuntu, it's possible that port 53 may be in use by systemd-resolved. 10 | To disable systemd-resolved and free up the port, follow [these instructions]. 11 | 12 | If you prefer to keep systemd-resolved and just disable the built-in resolver, you can use the following command: 13 | 14 | sed -i 's/#DNS=/DNS=9.9.9.9/; s/#DNSStubListener=yes/DNSStubListener=no/' /etc/systemd/resolved.conf 15 | systemctl restart systemd-resolved 16 | 17 | # How to Install 18 | 19 | The simplest way to install the software is by utilizing the pre-built binaries available on the releases page. 20 | Alternatively, there are other ways to install, which include: 21 | 22 | Using "go install" command: 23 | 24 | go install github.com/mosajjal/sniproxy/v2@latest 25 | 26 | Using Docker or Podman: 27 | 28 | docker run -d --pull always -p 80:80 -p 443:443 -p 53:53/udp -v "$(pwd)/config.defaults.yaml:/tmp/config.yaml" ghcr.io/mosajjal/sniproxy:latest --config /tmp/config.yaml 29 | 30 | Using the installer script: 31 | 32 | bash <(curl -L https://raw.githubusercontent.com/mosajjal/sniproxy/master/install.sh) 33 | 34 | # How to Run 35 | 36 | sniproxy can be configured using a configuration file or environment variables 37 | The configuration file is a YAML file, and an example configuration file can be found under [Sample config file]. 38 | you can find the instructions for the environment variables there as well. 39 | 40 | sniproxy [flags] 41 | 42 | Flags: 43 | 44 | -c, --config string path to YAML configuration file 45 | --defaultconfig write the default config yaml file to stdout 46 | -h, --help help for sniproxy 47 | -v, --version show version info and exit 48 | 49 | # Setting Up an SNI Proxy Using Vultr 50 | 51 | In this tutorial, we will go over the steps to set up an SNI proxy using Vultr as a service provider. This will allow you to serve multiple SSL-enabled websites from a single IP address. 52 | 53 | # Prerequisites 54 | 55 | - A Vultr account. If you don't have one, you can sign up for free using my [Vultr referal link] 56 | 57 | ## Step 1: Create a Vultr Server 58 | 59 | First, log in to your Vultr account and click on the "Instances" tab in the top menu. Then, click the "+" button to deploy a new server. 60 | 61 | On the "Deploy New Instance" page, select the following options: 62 | 63 | - Choose Server: Choose "Cloud Compute" 64 | - CPU & Storage Technology: Any of the choices should work perfectly fine 65 | - Server Location: Choose the location of the server. This will affect the latency of your website, so it's a good idea to choose a location that is close to your target audience. 66 | - Server Image: Any OS listed there is supported. If you're not sure what to choose, Ubuntu is a good option 67 | - Server Size: Choose a server size that is suitable for your needs. A small or medium-sized server should be sufficient for most SNI proxy setups. Pay attention to the monthly bandwidth usage as well 68 | - "Add Auto Backups": not strictly needed for sniproxy. 69 | - "SSH Keys": choose a SSH key to facilitate logging in later on. you can always use Vultr's builtin console as well. 70 | - Server Hostname: Choose a hostname for your server. This can be any name you like. 71 | After you have selected the appropriate options, click the "Deploy Now" button to create your server. 72 | 73 | ## Step 2: Install the SNI Proxy 74 | 75 | Once your server has been created, log in to the server using SSH or console. The root password is available under the "Overview" tab in instances list. 76 | 77 | Ensure the firewall (firewalld, ufw or iptables) is allowing connectivity to ports 80/TCP, 443/TCP and 53/UDP. For `ufw`, allow these ports with: 78 | 79 | sudo ufw allow 80/tcp 80 | sudo ufw allow 443/tcp 81 | sudo ufw allow 53/udp 82 | sudo ufw reload 83 | 84 | once you have a shell in front of you, run the following (assuming you're on Ubuntu 22.04) 85 | 86 | bash <(curl -L https://raw.githubusercontent.com/mosajjal/sniproxy/master/install.sh) 87 | 88 | above script is an interactive installer, it will ask you a few questions and then install sniproxy for you. it also installs sniproxy as a systemd servers, and enables it to start on boot. 89 | 90 | # step 3: customize your configuration 91 | 92 | above wizard will set up execution arguments for sniproxy. you can edit them by running 93 | 94 | sudo vim /opt/sniproxy/sniproxy.yaml 95 | 96 | and edit parameters as you see fit. for example, you can add more domains to the list of domains to proxy, or change the port numbers. 97 | 98 | [byosh]: https://github.com/mosajjal/byosh 99 | [SimpleSNIProxy]: https://github.com/ziozzang/SimpleSNIProxy 100 | [these instructions]: https://gist.github.com/zoilomora/f7d264cefbb589f3f1b1fc2cea2c844c 101 | [Vultr referal link]: https://www.vultr.com/?ref=8578601 102 | [Sample config file]: ./config.sample.yaml 103 | */ 104 | package sniproxy 105 | -------------------------------------------------------------------------------- /pkg/doh/certtools.go: -------------------------------------------------------------------------------- 1 | // Package doh contains the logic for DNS over HTTPS. It provides a way to make decisions based on the connection information. 2 | package doh 3 | 4 | import ( 5 | "bytes" 6 | "crypto" 7 | cryptorand "crypto/rand" 8 | "crypto/rsa" 9 | "crypto/x509" 10 | "crypto/x509/pkix" 11 | "encoding/pem" 12 | "fmt" 13 | "math/big" 14 | "net" 15 | "os" 16 | "path/filepath" 17 | "time" 18 | ) 19 | 20 | const ( 21 | duration365d = time.Hour * 24 * 365 22 | // CertificateBlockType is a possible value for pem.Block.Type. 23 | CertificateBlockType = "CERTIFICATE" 24 | // CertificateRequestBlockType is a possible value for pem.Block.Type. 25 | CertificateRequestBlockType = "CERTIFICATE REQUEST" 26 | // ECPrivateKeyBlockType is a possible value for pem.Block.Type. 27 | ECPrivateKeyBlockType = "EC PRIVATE KEY" 28 | // RSAPrivateKeyBlockType is a possible value for pem.Block.Type. 29 | RSAPrivateKeyBlockType = "RSA PRIVATE KEY" 30 | // PrivateKeyBlockType is a possible value for pem.Block.Type. 31 | PrivateKeyBlockType = "PRIVATE KEY" 32 | // PublicKeyBlockType is a possible value for pem.Block.Type. 33 | PublicKeyBlockType = "PUBLIC KEY" 34 | ) 35 | 36 | // Config contains the basic fields required for creating a certificate 37 | type Config struct { 38 | CommonName string 39 | Organization []string 40 | AltNames AltNames 41 | Usages []x509.ExtKeyUsage 42 | } 43 | 44 | // AltNames contains the domain names and IP addresses that will be added 45 | // to the API Server's x509 certificate SubAltNames field. The values will 46 | // be passed directly to the x509.Certificate object. 47 | type AltNames struct { 48 | DNSNames []string 49 | IPs []net.IP 50 | } 51 | 52 | // NewSelfSignedCACert creates a CA certificate 53 | func NewSelfSignedCACert(cfg Config, key crypto.Signer) (*x509.Certificate, error) { 54 | now := time.Now() 55 | tmpl := x509.Certificate{ 56 | SerialNumber: new(big.Int).SetInt64(0), 57 | Subject: pkix.Name{ 58 | CommonName: cfg.CommonName, 59 | Organization: cfg.Organization, 60 | }, 61 | DNSNames: []string{cfg.CommonName}, 62 | NotBefore: now.UTC(), 63 | NotAfter: now.Add(duration365d * 10).UTC(), 64 | KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign, 65 | BasicConstraintsValid: true, 66 | IsCA: true, 67 | } 68 | 69 | certDERBytes, err := x509.CreateCertificate(cryptorand.Reader, &tmpl, &tmpl, key.Public(), key) 70 | if err != nil { 71 | return nil, err 72 | } 73 | return x509.ParseCertificate(certDERBytes) 74 | } 75 | 76 | // GenerateSelfSignedCertKey creates a self-signed certificate and key for the given host. 77 | // Host may be an IP or a DNS name 78 | // You may also specify additional subject alt names (either ip or dns names) for the certificate. 79 | func GenerateSelfSignedCertKey(host string, alternateIPs []net.IP, alternateDNS []string, fixtureDirectory string) ([]byte, []byte, error) { 80 | return GenerateSelfSignedCertKeyWithFixtures(host, alternateIPs, alternateDNS, fixtureDirectory) 81 | } 82 | 83 | // GenerateSelfSignedCertKeyWithFixtures creates a self-signed certificate and key for the given host. 84 | // Host may be an IP or a DNS name. You may also specify additional subject alt names (either ip or dns names) 85 | // for the certificate. 86 | // 87 | // If fixtureDirectory is non-empty, it is a directory path which can contain pre-generated certs. The format is: 88 | // .crt 89 | // .key 90 | // Certs/keys not existing in that directory are created. 91 | func GenerateSelfSignedCertKeyWithFixtures(host string, alternateIPs []net.IP, alternateDNS []string, fixtureDirectory string) ([]byte, []byte, error) { 92 | validFrom := time.Now().Add(-time.Hour) // valid an hour earlier to avoid flakes due to clock skew 93 | maxAge := time.Hour * 24 * 365 // one year self-signed certs 94 | certFixturePath := filepath.Join(fixtureDirectory, host+".crt") 95 | keyFixturePath := filepath.Join(fixtureDirectory, host+".key") 96 | if len(fixtureDirectory) > 0 { 97 | cert, err := os.ReadFile(certFixturePath) 98 | if err == nil { 99 | key, err := os.ReadFile(keyFixturePath) 100 | if err == nil { 101 | return cert, key, nil 102 | } 103 | return nil, nil, fmt.Errorf("cert %s can be read, but key %s cannot: %v", certFixturePath, keyFixturePath, err) 104 | } 105 | maxAge = time.Hour * 24 * 36525 // 100 years fixtures 106 | } 107 | 108 | caKey, err := rsa.GenerateKey(cryptorand.Reader, 2048) 109 | if err != nil { 110 | return nil, nil, err 111 | } 112 | 113 | caTemplate := x509.Certificate{ 114 | SerialNumber: big.NewInt(1), 115 | Subject: pkix.Name{ 116 | CommonName: fmt.Sprintf("%s-ca@%d", host, time.Now().Unix()), 117 | }, 118 | NotBefore: validFrom, 119 | NotAfter: validFrom.Add(maxAge), 120 | 121 | KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign, 122 | BasicConstraintsValid: true, 123 | IsCA: true, 124 | } 125 | 126 | caDERBytes, err := x509.CreateCertificate(cryptorand.Reader, &caTemplate, &caTemplate, &caKey.PublicKey, caKey) 127 | if err != nil { 128 | return nil, nil, err 129 | } 130 | 131 | caCertificate, err := x509.ParseCertificate(caDERBytes) 132 | if err != nil { 133 | return nil, nil, err 134 | } 135 | 136 | priv, err := rsa.GenerateKey(cryptorand.Reader, 2048) 137 | if err != nil { 138 | return nil, nil, err 139 | } 140 | 141 | template := x509.Certificate{ 142 | SerialNumber: big.NewInt(2), 143 | Subject: pkix.Name{ 144 | CommonName: fmt.Sprintf("%s@%d", host, time.Now().Unix()), 145 | }, 146 | NotBefore: validFrom, 147 | NotAfter: validFrom.Add(maxAge), 148 | 149 | KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, 150 | ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, 151 | BasicConstraintsValid: true, 152 | } 153 | 154 | if ip := net.ParseIP(host); ip != nil { 155 | template.IPAddresses = append(template.IPAddresses, ip) 156 | } else { 157 | template.DNSNames = append(template.DNSNames, host) 158 | } 159 | 160 | template.IPAddresses = append(template.IPAddresses, alternateIPs...) 161 | template.DNSNames = append(template.DNSNames, alternateDNS...) 162 | 163 | derBytes, err := x509.CreateCertificate(cryptorand.Reader, &template, caCertificate, &priv.PublicKey, caKey) 164 | if err != nil { 165 | return nil, nil, err 166 | } 167 | 168 | // Generate cert, followed by ca 169 | certBuffer := bytes.Buffer{} 170 | if err := pem.Encode(&certBuffer, &pem.Block{Type: CertificateBlockType, Bytes: derBytes}); err != nil { 171 | return nil, nil, err 172 | } 173 | if err := pem.Encode(&certBuffer, &pem.Block{Type: CertificateBlockType, Bytes: caDERBytes}); err != nil { 174 | return nil, nil, err 175 | } 176 | 177 | // Generate key 178 | keyBuffer := bytes.Buffer{} 179 | if err := pem.Encode(&keyBuffer, &pem.Block{Type: RSAPrivateKeyBlockType, Bytes: x509.MarshalPKCS1PrivateKey(priv)}); err != nil { 180 | return nil, nil, err 181 | } 182 | 183 | if len(fixtureDirectory) > 0 { 184 | if err := os.WriteFile(certFixturePath, certBuffer.Bytes(), 0644); err != nil { 185 | return nil, nil, fmt.Errorf("failed to write cert fixture to %s: %v", certFixturePath, err) 186 | } 187 | if err := os.WriteFile(keyFixturePath, keyBuffer.Bytes(), 0644); err != nil { 188 | return nil, nil, fmt.Errorf("failed to write key fixture to %s: %v", certFixturePath, err) 189 | } 190 | } 191 | 192 | return certBuffer.Bytes(), keyBuffer.Bytes(), nil 193 | } 194 | -------------------------------------------------------------------------------- /pkg/doh/config.go: -------------------------------------------------------------------------------- 1 | /* 2 | DNS-over-HTTPS 3 | Copyright (C) 2017-2018 Star Brilliant 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a 6 | copy of this software and associated documentation files (the "Software"), 7 | to deal in the Software without restriction, including without limitation 8 | the rights to use, copy, modify, merge, publish, distribute, sublicense, 9 | and/or sell copies of the Software, and to permit persons to whom the 10 | Software is furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 20 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 21 | DEALINGS IN THE SOFTWARE. 22 | */ 23 | 24 | package doh 25 | 26 | import ( 27 | "regexp" 28 | ) 29 | 30 | type config struct { 31 | Listen []string `toml:"listen"` 32 | LocalAddr string `toml:"local_addr"` 33 | Cert string `toml:"cert"` 34 | Key string `toml:"key"` 35 | Path string `toml:"path"` 36 | Upstream []string `toml:"upstream"` 37 | Timeout uint `toml:"timeout"` 38 | Tries uint `toml:"tries"` 39 | Verbose bool `toml:"verbose"` 40 | DebugHTTPHeaders []string `toml:"debug_http_headers"` 41 | LogGuessedIP bool `toml:"log_guessed_client_ip"` 42 | ECSAllowNonGlobalIP bool `toml:"ecs_allow_non_global_ip"` 43 | ECSUsePreciseIP bool `toml:"ecs_use_precise_ip"` 44 | TLSClientAuth bool `toml:"tls_client_auth"` 45 | TLSClientAuthCA string `toml:"tls_client_auth_ca"` 46 | } 47 | 48 | var rxUpstreamWithTypePrefix = regexp.MustCompile("^[a-z-]+(:)") 49 | 50 | func addressAndType(us string) (string, string) { 51 | p := rxUpstreamWithTypePrefix.FindStringSubmatchIndex(us) 52 | if len(p) != 4 { 53 | return "", "" 54 | } 55 | 56 | return us[p[2]+1:], us[:p[2]] 57 | } 58 | 59 | type configError struct { 60 | err string 61 | } 62 | 63 | func (e *configError) Error() string { 64 | return e.err 65 | } 66 | -------------------------------------------------------------------------------- /pkg/doh/google.go: -------------------------------------------------------------------------------- 1 | /* 2 | DNS-over-HTTPS 3 | Copyright (C) 2017-2018 Star Brilliant 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a 6 | copy of this software and associated documentation files (the "Software"), 7 | to deal in the Software without restriction, including without limitation 8 | the rights to use, copy, modify, merge, publish, distribute, sublicense, 9 | and/or sell copies of the Software, and to permit persons to whom the 10 | Software is furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 20 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 21 | DEALINGS IN THE SOFTWARE. 22 | */ 23 | 24 | package doh 25 | 26 | import ( 27 | "context" 28 | "encoding/json" 29 | "fmt" 30 | "log" 31 | "net" 32 | "net/http" 33 | "strconv" 34 | "strings" 35 | "time" 36 | 37 | jsondns "github.com/m13253/dns-over-https/v2/json-dns" 38 | "github.com/miekg/dns" 39 | "golang.org/x/net/idna" 40 | ) 41 | 42 | func (s *Server) parseRequestGoogle(_ context.Context, _ http.ResponseWriter, r *http.Request) *DNSRequest { 43 | name := r.FormValue("name") 44 | if name == "" { 45 | return &DNSRequest{ 46 | errcode: 400, 47 | errtext: "Invalid argument value: \"name\"", 48 | } 49 | } 50 | if punycode, err := idna.ToASCII(name); err == nil { 51 | name = punycode 52 | } else { 53 | return &DNSRequest{ 54 | errcode: 400, 55 | errtext: fmt.Sprintf("Invalid argument value: \"name\" = %q (%s)", name, err.Error()), 56 | } 57 | } 58 | 59 | rrTypeStr := r.FormValue("type") 60 | rrType := uint16(1) 61 | if rrTypeStr == "" { 62 | // Do nothing and continue 63 | } else if v, err := strconv.ParseUint(rrTypeStr, 10, 16); err == nil { 64 | rrType = uint16(v) 65 | } else if v, ok := dns.StringToType[strings.ToUpper(rrTypeStr)]; ok { 66 | rrType = v 67 | } else { 68 | return &DNSRequest{ 69 | errcode: 400, 70 | errtext: fmt.Sprintf("Invalid argument value: \"type\" = %q", rrTypeStr), 71 | } 72 | } 73 | 74 | cdStr := r.FormValue("cd") 75 | cd := false 76 | if cdStr == "1" || strings.EqualFold(cdStr, "true") { 77 | cd = true 78 | } else if cdStr == "0" || strings.EqualFold(cdStr, "false") || cdStr == "" { 79 | // Do nothing and continue 80 | } else { 81 | return &DNSRequest{ 82 | errcode: 400, 83 | errtext: fmt.Sprintf("Invalid argument value: \"cd\" = %q", cdStr), 84 | } 85 | } 86 | 87 | ednsClientSubnet := r.FormValue("edns_client_subnet") 88 | ednsClientFamily := uint16(0) 89 | ednsClientAddress := net.IP(nil) 90 | ednsClientNetmask := uint8(255) 91 | if ednsClientSubnet != "" { 92 | if ednsClientSubnet == "0/0" { 93 | ednsClientSubnet = "0.0.0.0/0" 94 | } 95 | 96 | var err error 97 | ednsClientFamily, ednsClientAddress, ednsClientNetmask, err = parseSubnet(ednsClientSubnet) 98 | if err != nil { 99 | return &DNSRequest{ 100 | errcode: 400, 101 | errtext: err.Error(), 102 | } 103 | } 104 | } else { 105 | ednsClientAddress = s.findClientIP(r) 106 | if ednsClientAddress == nil { 107 | ednsClientNetmask = 0 108 | } else if ipv4 := ednsClientAddress.To4(); ipv4 != nil { 109 | ednsClientFamily = 1 110 | ednsClientAddress = ipv4 111 | ednsClientNetmask = 24 112 | } else { 113 | ednsClientFamily = 2 114 | ednsClientNetmask = 56 115 | } 116 | } 117 | 118 | msg := new(dns.Msg) 119 | msg.SetQuestion(dns.Fqdn(name), rrType) 120 | msg.CheckingDisabled = cd 121 | opt := new(dns.OPT) 122 | opt.Hdr.Name = "." 123 | opt.Hdr.Rrtype = dns.TypeOPT 124 | opt.SetUDPSize(dns.DefaultMsgSize) 125 | opt.SetDo(true) 126 | if ednsClientAddress != nil { 127 | edns0Subnet := new(dns.EDNS0_SUBNET) 128 | edns0Subnet.Code = dns.EDNS0SUBNET 129 | edns0Subnet.Family = ednsClientFamily 130 | edns0Subnet.SourceNetmask = ednsClientNetmask 131 | edns0Subnet.SourceScope = 0 132 | edns0Subnet.Address = ednsClientAddress 133 | opt.Option = append(opt.Option, edns0Subnet) 134 | } 135 | msg.Extra = append(msg.Extra, opt) 136 | 137 | return &DNSRequest{ 138 | request: msg, 139 | isTailored: ednsClientSubnet == "", 140 | } 141 | } 142 | 143 | func parseSubnet(ednsClientSubnet string) (ednsClientFamily uint16, ednsClientAddress net.IP, ednsClientNetmask uint8, err error) { 144 | slash := strings.IndexByte(ednsClientSubnet, '/') 145 | if slash < 0 { 146 | ednsClientAddress = net.ParseIP(ednsClientSubnet) 147 | if ednsClientAddress == nil { 148 | err = fmt.Errorf("Invalid argument value: \"edns_client_subnet\" = %q", ednsClientSubnet) 149 | return 150 | } 151 | if ipv4 := ednsClientAddress.To4(); ipv4 != nil { 152 | ednsClientFamily = 1 153 | ednsClientAddress = ipv4 154 | ednsClientNetmask = 24 155 | } else { 156 | ednsClientFamily = 2 157 | ednsClientNetmask = 56 158 | } 159 | } else { 160 | ednsClientAddress = net.ParseIP(ednsClientSubnet[:slash]) 161 | if ednsClientAddress == nil { 162 | err = fmt.Errorf("Invalid argument value: \"edns_client_subnet\" = %q", ednsClientSubnet) 163 | return 164 | } 165 | if ipv4 := ednsClientAddress.To4(); ipv4 != nil { 166 | ednsClientFamily = 1 167 | ednsClientAddress = ipv4 168 | } else { 169 | ednsClientFamily = 2 170 | } 171 | netmask, err1 := strconv.ParseUint(ednsClientSubnet[slash+1:], 10, 8) 172 | if err1 != nil { 173 | err = fmt.Errorf("Invalid argument value: \"edns_client_subnet\" = %q", ednsClientSubnet) 174 | return 175 | } 176 | ednsClientNetmask = uint8(netmask) 177 | } 178 | 179 | return 180 | } 181 | 182 | func (s *Server) generateResponseGoogle(_ context.Context, w http.ResponseWriter, _ *http.Request, req *DNSRequest) { 183 | respJSON := jsondns.Marshal(req.response) 184 | respStr, err := json.Marshal(respJSON) 185 | if err != nil { 186 | log.Println(err) 187 | jsondns.FormatError(w, fmt.Sprintf("DNS packet parse failure (%s)", err.Error()), 500) 188 | return 189 | } 190 | 191 | w.Header().Set("Content-Type", "application/json; charset=UTF-8") 192 | now := time.Now().UTC().Format(http.TimeFormat) 193 | w.Header().Set("Date", now) 194 | w.Header().Set("Last-Modified", now) 195 | w.Header().Set("Vary", "Accept") 196 | if respJSON.HaveTTL { 197 | if req.isTailored { 198 | w.Header().Set("Cache-Control", "private, max-age="+strconv.FormatUint(uint64(respJSON.LeastTTL), 10)) 199 | } else { 200 | w.Header().Set("Cache-Control", "public, max-age="+strconv.FormatUint(uint64(respJSON.LeastTTL), 10)) 201 | } 202 | w.Header().Set("Expires", respJSON.EarliestExpires.Format(http.TimeFormat)) 203 | } 204 | if respJSON.Status == dns.RcodeServerFailure { 205 | w.WriteHeader(503) 206 | } 207 | w.Write(respStr) 208 | } 209 | -------------------------------------------------------------------------------- /pkg/doh/ietf.go: -------------------------------------------------------------------------------- 1 | /* 2 | DNS-over-HTTPS 3 | Copyright (C) 2017-2018 Star Brilliant 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a 6 | copy of this software and associated documentation files (the "Software"), 7 | to deal in the Software without restriction, including without limitation 8 | the rights to use, copy, modify, merge, publish, distribute, sublicense, 9 | and/or sell copies of the Software, and to permit persons to whom the 10 | Software is furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 20 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 21 | DEALINGS IN THE SOFTWARE. 22 | */ 23 | 24 | package doh 25 | 26 | import ( 27 | "bytes" 28 | "context" 29 | "encoding/base64" 30 | "fmt" 31 | "io" 32 | "log" 33 | "net" 34 | "net/http" 35 | "strconv" 36 | "strings" 37 | "time" 38 | 39 | jsondns "github.com/m13253/dns-over-https/v2/json-dns" 40 | "github.com/miekg/dns" 41 | ) 42 | 43 | func (s *Server) parseRequestIETF(_ context.Context, w http.ResponseWriter, r *http.Request) *DNSRequest { 44 | requestBase64 := r.FormValue("dns") 45 | requestBinary, err := base64.RawURLEncoding.DecodeString(requestBase64) 46 | if err != nil { 47 | return &DNSRequest{ 48 | errcode: 400, 49 | errtext: fmt.Sprintf("Invalid argument value: \"dns\" = %q", requestBase64), 50 | } 51 | } 52 | if len(requestBinary) == 0 && (r.Header.Get("Content-Type") == "application/dns-message" || r.Header.Get("Content-Type") == "application/dns-udpwireformat") { 53 | requestBinary, err = io.ReadAll(r.Body) 54 | if err != nil { 55 | return &DNSRequest{ 56 | errcode: 400, 57 | errtext: fmt.Sprintf("Failed to read request body (%s)", err.Error()), 58 | } 59 | } 60 | } 61 | if len(requestBinary) == 0 { 62 | return &DNSRequest{ 63 | errcode: 400, 64 | errtext: fmt.Sprintf("Invalid argument value: \"dns\""), 65 | } 66 | } 67 | 68 | if s.patchDNSCryptProxyReqID(w, r, requestBinary) { 69 | return &DNSRequest{ 70 | errcode: 444, 71 | } 72 | } 73 | 74 | msg := new(dns.Msg) 75 | err = msg.Unpack(requestBinary) 76 | if err != nil { 77 | return &DNSRequest{ 78 | errcode: 400, 79 | errtext: fmt.Sprintf("DNS packet parse failure (%s)", err.Error()), 80 | } 81 | } 82 | 83 | if s.conf.Verbose && len(msg.Question) > 0 { 84 | question := &msg.Question[0] 85 | questionName := question.Name 86 | questionClass := "" 87 | if qclass, ok := dns.ClassToString[question.Qclass]; ok { 88 | questionClass = qclass 89 | } else { 90 | questionClass = strconv.FormatUint(uint64(question.Qclass), 10) 91 | } 92 | questionType := "" 93 | if qtype, ok := dns.TypeToString[question.Qtype]; ok { 94 | questionType = qtype 95 | } else { 96 | questionType = strconv.FormatUint(uint64(question.Qtype), 10) 97 | } 98 | var clientip net.IP 99 | if s.conf.LogGuessedIP { 100 | clientip = s.findClientIP(r) 101 | } 102 | if clientip != nil { 103 | fmt.Printf("%s - - [%s] \"%s %s %s\"\n", clientip, time.Now().Format("02/Jan/2006:15:04:05 -0700"), questionName, questionClass, questionType) 104 | } else { 105 | fmt.Printf("%s - - [%s] \"%s %s %s\"\n", r.RemoteAddr, time.Now().Format("02/Jan/2006:15:04:05 -0700"), questionName, questionClass, questionType) 106 | } 107 | } 108 | 109 | transactionID := msg.Id 110 | msg.Id = dns.Id() 111 | opt := msg.IsEdns0() 112 | if opt == nil { 113 | opt = new(dns.OPT) 114 | opt.Hdr.Name = "." 115 | opt.Hdr.Rrtype = dns.TypeOPT 116 | opt.SetUDPSize(dns.DefaultMsgSize) 117 | opt.SetDo(false) 118 | msg.Extra = append([]dns.RR{opt}, msg.Extra...) 119 | } 120 | var edns0Subnet *dns.EDNS0_SUBNET 121 | for _, option := range opt.Option { 122 | if option.Option() == dns.EDNS0SUBNET { 123 | edns0Subnet = option.(*dns.EDNS0_SUBNET) 124 | break 125 | } 126 | } 127 | isTailored := edns0Subnet == nil 128 | 129 | if edns0Subnet == nil { 130 | ednsClientFamily := uint16(0) 131 | ednsClientAddress := s.findClientIP(r) 132 | ednsClientNetmask := uint8(255) 133 | if ednsClientAddress != nil { 134 | if ipv4 := ednsClientAddress.To4(); ipv4 != nil { 135 | ednsClientFamily = 1 136 | ednsClientAddress = ipv4 137 | if s.conf.ECSUsePreciseIP { 138 | ednsClientNetmask = 32 139 | } else { 140 | ednsClientNetmask = 24 141 | ednsClientAddress = ednsClientAddress.Mask(net.CIDRMask(24, 32)) 142 | } 143 | } else { 144 | ednsClientFamily = 2 145 | if s.conf.ECSUsePreciseIP { 146 | ednsClientNetmask = 128 147 | } else { 148 | ednsClientNetmask = 56 149 | ednsClientAddress = ednsClientAddress.Mask(net.CIDRMask(56, 128)) 150 | } 151 | } 152 | edns0Subnet = new(dns.EDNS0_SUBNET) 153 | edns0Subnet.Code = dns.EDNS0SUBNET 154 | edns0Subnet.Family = ednsClientFamily 155 | edns0Subnet.SourceNetmask = ednsClientNetmask 156 | edns0Subnet.SourceScope = 0 157 | edns0Subnet.Address = ednsClientAddress 158 | opt.Option = append(opt.Option, edns0Subnet) 159 | } 160 | } 161 | 162 | return &DNSRequest{ 163 | request: msg, 164 | transactionID: transactionID, 165 | isTailored: isTailored, 166 | } 167 | } 168 | 169 | func (s *Server) generateResponseIETF(_ context.Context, w http.ResponseWriter, _ *http.Request, req *DNSRequest) { 170 | respJSON := jsondns.Marshal(req.response) 171 | req.response.Id = req.transactionID 172 | respBytes, err := req.response.Pack() 173 | if err != nil { 174 | log.Printf("DNS packet construct failure with upstream %s: %v\n", req.currentUpstream, err) 175 | jsondns.FormatError(w, fmt.Sprintf("DNS packet construct failure (%s)", err.Error()), 500) 176 | return 177 | } 178 | 179 | w.Header().Set("Content-Type", "application/dns-message") 180 | now := time.Now().UTC().Format(http.TimeFormat) 181 | w.Header().Set("Date", now) 182 | w.Header().Set("Last-Modified", now) 183 | w.Header().Set("Vary", "Accept") 184 | 185 | if respJSON.HaveTTL { 186 | if req.isTailored { 187 | w.Header().Set("Cache-Control", "private, max-age="+strconv.FormatUint(uint64(respJSON.LeastTTL), 10)) 188 | } else { 189 | w.Header().Set("Cache-Control", "public, max-age="+strconv.FormatUint(uint64(respJSON.LeastTTL), 10)) 190 | } 191 | w.Header().Set("Expires", respJSON.EarliestExpires.Format(http.TimeFormat)) 192 | } 193 | 194 | if respJSON.Status == dns.RcodeServerFailure { 195 | log.Printf("received server failure from upstream %s: %v\n", req.currentUpstream, req.response) 196 | w.WriteHeader(503) 197 | } 198 | _, err = w.Write(respBytes) 199 | if err != nil { 200 | log.Printf("failed to write to client: %v\n", err) 201 | } 202 | } 203 | 204 | // Workaround a bug causing DNSCrypt-Proxy to expect a response with TransactionID = 0xcafe 205 | func (s *Server) patchDNSCryptProxyReqID(w http.ResponseWriter, r *http.Request, requestBinary []byte) bool { 206 | if strings.Contains(r.UserAgent(), "dnscrypt-proxy") && bytes.Equal(requestBinary, []byte("\xca\xfe\x01\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x02\x00\x01\x00\x00\x29\x10\x00\x00\x00\x80\x00\x00\x00")) { 207 | if s.conf.Verbose { 208 | log.Println("DNSCrypt-Proxy detected. Patching response.") 209 | } 210 | w.Header().Set("Content-Type", "application/dns-message") 211 | w.Header().Set("Vary", "Accept, User-Agent") 212 | now := time.Now().UTC().Format(http.TimeFormat) 213 | w.Header().Set("Date", now) 214 | w.Write([]byte("\xca\xfe\x81\x05\x00\x01\x00\x01\x00\x00\x00\x00\x00\x00\x02\x00\x01\x00\x00\x10\x00\x01\x00\x00\x00\x00\x00\xa8\xa7\r\nWorkaround a bug causing DNSCrypt-Proxy to expect a response with TransactionID = 0xcafe\r\nRefer to https://github.com/jedisct1/dnscrypt-proxy/issues/526 for details.")) 215 | return true 216 | } 217 | return false 218 | } 219 | -------------------------------------------------------------------------------- /pkg/doh/main.go: -------------------------------------------------------------------------------- 1 | /* 2 | DNS-over-HTTPS 3 | Copyright (C) 2017-2018 Star Brilliant 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a 6 | copy of this software and associated documentation files (the "Software"), 7 | to deal in the Software without restriction, including without limitation 8 | the rights to use, copy, modify, merge, publish, distribute, sublicense, 9 | and/or sell copies of the Software, and to permit persons to whom the 10 | Software is furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 20 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 21 | DEALINGS IN THE SOFTWARE. 22 | */ 23 | 24 | package doh 25 | 26 | import ( 27 | "fmt" 28 | "io" 29 | "log" 30 | "os" 31 | "strconv" 32 | ) 33 | 34 | func checkPIDFile(pidFile string) (bool, error) { 35 | retry: 36 | f, err := os.OpenFile(pidFile, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0666) 37 | if os.IsExist(err) { 38 | pidStr, err := os.ReadFile(pidFile) 39 | if err != nil { 40 | return false, err 41 | } 42 | pid, err := strconv.ParseUint(string(pidStr), 10, 0) 43 | if err != nil { 44 | return false, err 45 | } 46 | _, err = os.Stat(fmt.Sprintf("/proc/%d", pid)) 47 | if os.IsNotExist(err) { 48 | err = os.Remove(pidFile) 49 | if err != nil { 50 | return false, err 51 | } 52 | goto retry 53 | } else if err != nil { 54 | return false, err 55 | } 56 | log.Printf("Already running on PID %d, exiting.\n", pid) 57 | return false, nil 58 | } else if err != nil { 59 | return false, err 60 | } 61 | defer f.Close() 62 | _, err = io.WriteString(f, strconv.FormatInt(int64(os.Getpid()), 10)) 63 | if err != nil { 64 | return false, err 65 | } 66 | return true, nil 67 | } 68 | -------------------------------------------------------------------------------- /pkg/doh/parse_test.go: -------------------------------------------------------------------------------- 1 | /* 2 | DNS-over-HTTPS 3 | Copyright (C) 2017-2018 Star Brilliant 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a 6 | copy of this software and associated documentation files (the "Software"), 7 | to deal in the Software without restriction, including without limitation 8 | the rights to use, copy, modify, merge, publish, distribute, sublicense, 9 | and/or sell copies of the Software, and to permit persons to whom the 10 | Software is furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 20 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 21 | DEALINGS IN THE SOFTWARE. 22 | */ 23 | 24 | package doh 25 | 26 | import ( 27 | "testing" 28 | 29 | "github.com/miekg/dns" 30 | ) 31 | 32 | func TestParseCIDR(t *testing.T) { 33 | t.Parallel() 34 | for _, ednsClientSubnet := range []string{ 35 | "2001:db8::/0", 36 | "2001:db8::/56", 37 | "2001:db8::/129", 38 | "2001:db8::", 39 | 40 | "127.0.0.1/0", 41 | "127.0.0.1/24", 42 | "127.0.0.1/33", 43 | "127.0.0.1", 44 | 45 | "::ffff:7f00:1/0", 46 | "::ffff:7f00:1/120", 47 | "::ffff:7f00:1", 48 | "127.0.0.1/0", 49 | "127.0.0.1/24", 50 | "127.0.0.1", 51 | } { 52 | _, ip, ipNet, err := parseSubnet(ednsClientSubnet) 53 | if err != nil { 54 | t.Errorf("ecs:%s ip:[%v] ipNet:[%v] err:[%v]", ednsClientSubnet, ip, ipNet, err) 55 | } 56 | } 57 | } 58 | 59 | func TestParseInvalidCIDR(t *testing.T) { 60 | t.Parallel() 61 | 62 | for _, ip := range []string{ 63 | "test", 64 | "test/0", 65 | "test/24", 66 | "test/34", 67 | "test/56", 68 | "test/129", 69 | } { 70 | _, _, _, err := parseSubnet(ip) 71 | if err == nil { 72 | t.Errorf("expected error for %q", ip) 73 | } 74 | } 75 | } 76 | 77 | func TestEdns0SubnetParseCIDR(t *testing.T) { 78 | t.Parallel() 79 | // init dns Msg 80 | msg := new(dns.Msg) 81 | msg.Id = dns.Id() 82 | msg.SetQuestion(dns.Fqdn("example.com"), 1) 83 | 84 | // init edns0Subnet 85 | edns0Subnet := new(dns.EDNS0_SUBNET) 86 | edns0Subnet.Code = dns.EDNS0SUBNET 87 | edns0Subnet.SourceScope = 0 88 | 89 | // init opt 90 | opt := new(dns.OPT) 91 | opt.Hdr.Name = "." 92 | opt.Hdr.Rrtype = dns.TypeOPT 93 | opt.SetUDPSize(dns.DefaultMsgSize) 94 | 95 | opt.Option = append(opt.Option, edns0Subnet) 96 | msg.Extra = append(msg.Extra, opt) 97 | 98 | for _, subnet := range []string{"::ffff:7f00:1/120", "127.0.0.1/24"} { 99 | var err error 100 | edns0Subnet.Family, edns0Subnet.Address, edns0Subnet.SourceNetmask, err = parseSubnet(subnet) 101 | if err != nil { 102 | t.Error(err) 103 | continue 104 | } 105 | // packedMsg, _ := msg.Pack() 106 | t.Logf("msg: %#+v", msg) 107 | } 108 | 109 | // ------127.0.0.1/24----- 110 | // [143 29 1 0 0 1 0 0 0 0 0 1 7 101 120 97 109 112 108 101 3 99 111 109 0 0 1 0 1 0 111 | // opt start 0 41 16 0 0 0 0 0 0 11 112 | // subnet start 0 8 0 7 0 1 24 0 113 | // client subnet start 127 0 0] 114 | 115 | // -----::ffff:7f00:1/120---- 116 | // [111 113 1 0 0 1 0 0 0 0 0 1 7 101 120 97 109 112 108 101 3 99 111 109 0 0 1 0 1 0 117 | // opt start 0 41 16 0 0 0 0 0 0 23 118 | // subnet start 0 8 0 19 0 2 120 0 119 | // client subnet start 0 0 0 0 0 0 0 0 0 0 255 255 127 0 0] 120 | } 121 | -------------------------------------------------------------------------------- /pkg/doh/server.go: -------------------------------------------------------------------------------- 1 | /* 2 | DNS-over-HTTPS 3 | Copyright (C) 2017-2018 Star Brilliant 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a 6 | copy of this software and associated documentation files (the "Software"), 7 | to deal in the Software without restriction, including without limitation 8 | the rights to use, copy, modify, merge, publish, distribute, sublicense, 9 | and/or sell copies of the Software, and to permit persons to whom the 10 | Software is furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 20 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 21 | DEALINGS IN THE SOFTWARE. 22 | */ 23 | 24 | package doh 25 | 26 | import ( 27 | "context" 28 | "crypto/tls" 29 | "crypto/x509" 30 | "fmt" 31 | "log" 32 | "math/rand" 33 | "net" 34 | "net/http" 35 | "os" 36 | "strings" 37 | "time" 38 | 39 | "github.com/gorilla/handlers" 40 | jsondns "github.com/m13253/dns-over-https/v2/json-dns" 41 | "github.com/miekg/dns" 42 | ) 43 | 44 | // Server is a DNS-over-HTTPS server runtime 45 | type Server struct { 46 | conf *config 47 | udpClient *dns.Client 48 | tcpClient *dns.Client 49 | tcpClientTLS *dns.Client 50 | servemux *http.ServeMux 51 | } 52 | 53 | // DNSRequest is a DNS request 54 | type DNSRequest struct { 55 | request *dns.Msg 56 | response *dns.Msg 57 | transactionID uint16 58 | currentUpstream string 59 | isTailored bool 60 | errcode int 61 | errtext string 62 | } 63 | 64 | // NewDefaultConfig creates a new default config 65 | func NewDefaultConfig() *config { 66 | conf := &config{} 67 | if len(conf.Listen) == 0 { 68 | conf.Listen = []string{"127.0.0.1:8053", "[::1]:8053"} 69 | } 70 | 71 | if conf.Path == "" { 72 | conf.Path = "/dns-query" 73 | } 74 | if len(conf.Upstream) == 0 { 75 | conf.Upstream = []string{"udp:8.8.8.8:53", "udp:8.8.4.4:53"} 76 | } 77 | if conf.Timeout == 0 { 78 | conf.Timeout = 10 79 | } 80 | if conf.Tries == 0 { 81 | conf.Tries = 1 82 | } 83 | return conf 84 | } 85 | 86 | // NewServer creates a new Server 87 | func NewServer(conf *config) (*Server, error) { 88 | timeout := time.Duration(conf.Timeout) * time.Second 89 | s := &Server{ 90 | conf: conf, 91 | udpClient: &dns.Client{ 92 | Net: "udp", 93 | UDPSize: dns.DefaultMsgSize, 94 | Timeout: timeout, 95 | }, 96 | tcpClient: &dns.Client{ 97 | Net: "tcp", 98 | Timeout: timeout, 99 | }, 100 | tcpClientTLS: &dns.Client{ 101 | Net: "tcp-tls", 102 | Timeout: timeout, 103 | }, 104 | servemux: http.NewServeMux(), 105 | } 106 | if conf.LocalAddr != "" { 107 | udpLocalAddr, err := net.ResolveUDPAddr("udp", conf.LocalAddr) 108 | if err != nil { 109 | return nil, err 110 | } 111 | tcpLocalAddr, err := net.ResolveTCPAddr("tcp", conf.LocalAddr) 112 | if err != nil { 113 | return nil, err 114 | } 115 | s.udpClient.Dialer = &net.Dialer{ 116 | Timeout: timeout, 117 | LocalAddr: udpLocalAddr, 118 | } 119 | s.tcpClient.Dialer = &net.Dialer{ 120 | Timeout: timeout, 121 | LocalAddr: tcpLocalAddr, 122 | } 123 | s.tcpClientTLS.Dialer = &net.Dialer{ 124 | Timeout: timeout, 125 | LocalAddr: tcpLocalAddr, 126 | } 127 | } 128 | s.servemux.HandleFunc(conf.Path, s.handlerFunc) 129 | return s, nil 130 | } 131 | 132 | // Start starts the server 133 | func (s *Server) Start() error { 134 | servemux := http.Handler(s.servemux) 135 | if s.conf.Verbose { 136 | servemux = handlers.CombinedLoggingHandler(os.Stdout, servemux) 137 | } 138 | 139 | var clientCAPool *x509.CertPool 140 | if s.conf.TLSClientAuth { 141 | if s.conf.TLSClientAuthCA != "" { 142 | clientCA, err := os.ReadFile(s.conf.TLSClientAuthCA) 143 | if err != nil { 144 | log.Fatalf("Reading certificate for client authentication has failed: %v", err) 145 | } 146 | clientCAPool = x509.NewCertPool() 147 | clientCAPool.AppendCertsFromPEM(clientCA) 148 | log.Println("Certificate loaded for client TLS authentication") 149 | } else { 150 | log.Fatalln("TLS client authentication requires both tls_client_auth and tls_client_auth_ca, exiting.") 151 | } 152 | } 153 | 154 | results := make(chan error, len(s.conf.Listen)) 155 | for _, addr := range s.conf.Listen { 156 | go func(addr string) { 157 | var err error 158 | if s.conf.Cert != "" || s.conf.Key != "" { 159 | if clientCAPool != nil { 160 | srvtls := &http.Server{ 161 | Handler: servemux, 162 | Addr: addr, 163 | TLSConfig: &tls.Config{ 164 | ClientCAs: clientCAPool, 165 | ClientAuth: tls.RequireAndVerifyClientCert, 166 | GetCertificate: func(_ *tls.ClientHelloInfo) (certificate *tls.Certificate, e error) { 167 | c, err := tls.LoadX509KeyPair(s.conf.Cert, s.conf.Key) 168 | if err != nil { 169 | fmt.Printf("Error loading server certificate key pair: %v\n", err) 170 | return nil, err 171 | } 172 | return &c, nil 173 | }, 174 | }, 175 | } 176 | err = srvtls.ListenAndServeTLS("", "") 177 | } else { 178 | err = http.ListenAndServeTLS(addr, s.conf.Cert, s.conf.Key, servemux) 179 | } 180 | } else { 181 | err = http.ListenAndServe(addr, servemux) 182 | } 183 | if err != nil { 184 | log.Println(err) 185 | } 186 | results <- err 187 | }(addr) 188 | } 189 | // wait for all handlers 190 | for i := 0; i < cap(results); i++ { 191 | err := <-results 192 | if err != nil { 193 | return err 194 | } 195 | } 196 | close(results) 197 | return nil 198 | } 199 | 200 | func (s *Server) handlerFunc(w http.ResponseWriter, r *http.Request) { 201 | ctx := r.Context() 202 | 203 | if realIP := r.Header.Get("X-Real-IP"); realIP != "" { 204 | if strings.ContainsRune(realIP, ':') { 205 | r.RemoteAddr = "[" + realIP + "]:0" 206 | } else { 207 | r.RemoteAddr = realIP + ":0" 208 | } 209 | _, _, err := net.SplitHostPort(r.RemoteAddr) 210 | if err != nil { 211 | r.RemoteAddr = realIP 212 | } 213 | } 214 | 215 | w.Header().Set("Access-Control-Allow-Headers", "Content-Type") 216 | w.Header().Set("Access-Control-Allow-Methods", "GET, HEAD, OPTIONS, POST") 217 | w.Header().Set("Access-Control-Allow-Origin", "*") 218 | w.Header().Set("Access-Control-Max-Age", "3600") 219 | w.Header().Set("Server", UserAgent) 220 | w.Header().Set("X-Powered-By", UserAgent) 221 | 222 | if r.Method == "OPTIONS" { 223 | w.Header().Set("Content-Length", "0") 224 | return 225 | } 226 | 227 | if r.Form == nil { 228 | const maxMemory = 32 << 20 // 32 MB 229 | r.ParseMultipartForm(maxMemory) 230 | } 231 | 232 | for _, header := range s.conf.DebugHTTPHeaders { 233 | if value := r.Header.Get(header); value != "" { 234 | log.Printf("%s: %s\n", header, value) 235 | } 236 | } 237 | 238 | contentType := r.Header.Get("Content-Type") 239 | if ct := r.FormValue("ct"); ct != "" { 240 | contentType = ct 241 | } 242 | if contentType == "" { 243 | // Guess request Content-Type based on other parameters 244 | if r.FormValue("name") != "" { 245 | contentType = "application/dns-json" 246 | } else if r.FormValue("dns") != "" { 247 | contentType = "application/dns-message" 248 | } 249 | } 250 | var responseType string 251 | for _, responseCandidate := range strings.Split(r.Header.Get("Accept"), ",") { 252 | responseCandidate = strings.SplitN(responseCandidate, ";", 2)[0] 253 | if responseCandidate == "application/json" { 254 | responseType = "application/json" 255 | break 256 | } else if responseCandidate == "application/dns-udpwireformat" { 257 | responseType = "application/dns-message" 258 | break 259 | } else if responseCandidate == "application/dns-message" { 260 | responseType = "application/dns-message" 261 | break 262 | } 263 | } 264 | if responseType == "" { 265 | // Guess response Content-Type based on request Content-Type 266 | if contentType == "application/dns-json" { 267 | responseType = "application/json" 268 | } else if contentType == "application/dns-message" { 269 | responseType = "application/dns-message" 270 | } else if contentType == "application/dns-udpwireformat" { 271 | responseType = "application/dns-message" 272 | } 273 | } 274 | 275 | var req *DNSRequest 276 | if contentType == "application/dns-json" { 277 | req = s.parseRequestGoogle(ctx, w, r) 278 | } else if contentType == "application/dns-message" { 279 | req = s.parseRequestIETF(ctx, w, r) 280 | } else if contentType == "application/dns-udpwireformat" { 281 | req = s.parseRequestIETF(ctx, w, r) 282 | } else { 283 | jsondns.FormatError(w, fmt.Sprintf("Invalid argument value: \"ct\" = %q", contentType), 415) 284 | return 285 | } 286 | if req.errcode == 444 { 287 | return 288 | } 289 | if req.errcode != 0 { 290 | jsondns.FormatError(w, req.errtext, req.errcode) 291 | return 292 | } 293 | 294 | req = s.patchRootRD(req) 295 | 296 | err := s.doDNSQuery(ctx, req) 297 | if err != nil { 298 | jsondns.FormatError(w, fmt.Sprintf("DNS query failure (%s)", err.Error()), 503) 299 | return 300 | } 301 | 302 | if responseType == "application/json" { 303 | s.generateResponseGoogle(ctx, w, r, req) 304 | } else if responseType == "application/dns-message" { 305 | s.generateResponseIETF(ctx, w, r, req) 306 | } else { 307 | panic("Unknown response Content-Type") 308 | } 309 | } 310 | 311 | func (s *Server) findClientIP(r *http.Request) net.IP { 312 | noEcs := r.URL.Query().Get("no_ecs") 313 | if strings.ToLower(noEcs) == "true" { 314 | return nil 315 | } 316 | 317 | XForwardedFor := r.Header.Get("X-Forwarded-For") 318 | if XForwardedFor != "" { 319 | for _, addr := range strings.Split(XForwardedFor, ",") { 320 | addr = strings.TrimSpace(addr) 321 | ip := net.ParseIP(addr) 322 | if jsondns.IsGlobalIP(ip) { 323 | return ip 324 | } 325 | } 326 | } 327 | XRealIP := r.Header.Get("X-Real-IP") 328 | if XRealIP != "" { 329 | addr := strings.TrimSpace(XRealIP) 330 | ip := net.ParseIP(addr) 331 | if s.conf.ECSAllowNonGlobalIP || jsondns.IsGlobalIP(ip) { 332 | return ip 333 | } 334 | } 335 | 336 | remoteAddr, err := net.ResolveTCPAddr("tcp", r.RemoteAddr) 337 | if err != nil { 338 | return nil 339 | } 340 | ip := remoteAddr.IP 341 | if s.conf.ECSAllowNonGlobalIP || jsondns.IsGlobalIP(ip) { 342 | return ip 343 | } 344 | return nil 345 | } 346 | 347 | // Workaround a bug causing Unbound to refuse returning anything about the root 348 | func (s *Server) patchRootRD(req *DNSRequest) *DNSRequest { 349 | for _, question := range req.request.Question { 350 | if question.Name == "." { 351 | req.request.RecursionDesired = true 352 | } 353 | } 354 | return req 355 | } 356 | 357 | // Return the position index for the question of qtype from a DNS msg, otherwise return -1 358 | func (s *Server) indexQuestionType(msg *dns.Msg, qtype uint16) int { 359 | for i, question := range msg.Question { 360 | if question.Qtype == qtype { 361 | return i 362 | } 363 | } 364 | return -1 365 | } 366 | 367 | func (s *Server) doDNSQuery(ctx context.Context, req *DNSRequest) (err error) { 368 | numServers := len(s.conf.Upstream) 369 | for i := uint(0); i < s.conf.Tries; i++ { 370 | req.currentUpstream = s.conf.Upstream[rand.Intn(numServers)] 371 | 372 | upstream, t := addressAndType(req.currentUpstream) 373 | 374 | switch t { 375 | default: 376 | log.Printf("invalid DNS type %q in upstream %q", t, upstream) 377 | return &configError{"invalid DNS type"} 378 | // Use DNS-over-TLS (DoT) if configured to do so 379 | case "tcp-tls": 380 | req.response, _, err = s.tcpClientTLS.ExchangeContext(ctx, req.request, upstream) 381 | case "tcp", "udp": 382 | // Use TCP if always configured to or if the Query type dictates it (AXFR) 383 | if t == "tcp" || (s.indexQuestionType(req.request, dns.TypeAXFR) > -1) { 384 | req.response, _, err = s.tcpClient.ExchangeContext(ctx, req.request, upstream) 385 | } else { 386 | req.response, _, err = s.udpClient.ExchangeContext(ctx, req.request, upstream) 387 | if err == nil && req.response != nil && req.response.Truncated { 388 | log.Println(err) 389 | req.response, _, err = s.tcpClient.ExchangeContext(ctx, req.request, upstream) 390 | } 391 | 392 | // Retry with TCP if this was an IXFR request and we only received an SOA 393 | if (s.indexQuestionType(req.request, dns.TypeIXFR) > -1) && 394 | (len(req.response.Answer) == 1) && 395 | (req.response.Answer[0].Header().Rrtype == dns.TypeSOA) { 396 | req.response, _, err = s.tcpClient.ExchangeContext(ctx, req.request, upstream) 397 | } 398 | } 399 | } 400 | 401 | if err == nil { 402 | return nil 403 | } 404 | log.Printf("DNS error from upstream %s: %s\n", req.currentUpstream, err.Error()) 405 | } 406 | return err 407 | } 408 | -------------------------------------------------------------------------------- /pkg/doh/version.go: -------------------------------------------------------------------------------- 1 | /* 2 | DNS-over-HTTPS 3 | Copyright (C) 2017-2018 Star Brilliant 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a 6 | copy of this software and associated documentation files (the "Software"), 7 | to deal in the Software without restriction, including without limitation 8 | the rights to use, copy, modify, merge, publish, distribute, sublicense, 9 | and/or sell copies of the Software, and to permit persons to whom the 10 | Software is furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 20 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 21 | DEALINGS IN THE SOFTWARE. 22 | */ 23 | 24 | package doh 25 | 26 | const ( 27 | // UserAgent is the default User-Agent string for the HTTP client 28 | UserAgent = "DNS-over-HTTPS (+https://github.com/mosajjal/sniproxy)" 29 | ) 30 | -------------------------------------------------------------------------------- /pkg/httpproxy.go: -------------------------------------------------------------------------------- 1 | package sniproxy 2 | 3 | import ( 4 | "io" 5 | "net" 6 | "net/http" 7 | "strings" 8 | 9 | "github.com/mosajjal/sniproxy/v2/pkg/acl" 10 | "github.com/rs/zerolog" 11 | ) 12 | 13 | var passthruRequestHeaderKeys = [...]string{ 14 | "Accept", 15 | "Accept-Encoding", 16 | "Accept-Language", 17 | "Cache-Control", 18 | "Cookie", 19 | "Referer", 20 | "User-Agent", 21 | } 22 | 23 | var passthruResponseHeaderKeys = [...]string{ 24 | "Content-Encoding", 25 | "Content-Language", 26 | "Content-Type", 27 | "Cache-Control", // TODO: Is this valid in a response? 28 | "Date", 29 | "Etag", 30 | "Expires", 31 | "Last-Modified", 32 | "Location", 33 | "Server", 34 | "Vary", 35 | } 36 | 37 | // RunHTTP starts the HTTP server on the configured bind. bind format is 0.0.0.0:80 or similar 38 | func RunHTTP(c *Config, bind string, l zerolog.Logger) { 39 | handler := http.NewServeMux() 40 | l = l.With().Str("service", "http").Str("listener", bind).Logger() 41 | 42 | handler.HandleFunc("/", handle80(c, l)) 43 | 44 | s := &http.Server{ 45 | Addr: bind, 46 | Handler: handler, 47 | ReadTimeout: HTTPReadTimeout, 48 | WriteTimeout: HTTPWriteTimeout, 49 | MaxHeaderBytes: 1 << 20, 50 | } 51 | 52 | l.Info().Str("bind", bind).Msg("starting http server") 53 | if err := s.ListenAndServe(); err != nil { 54 | l.Error().Msg(err.Error()) 55 | panic(-1) 56 | } 57 | } 58 | 59 | func handle80(c *Config, l zerolog.Logger) http.HandlerFunc { 60 | return func(w http.ResponseWriter, r *http.Request) { 61 | c.RecievedHTTP.Inc(1) 62 | 63 | // BUG: this line does not take preferred DNS server or ipv4/ipv6 into account 64 | // NOTE: currently, this line leaks DNS Queries to the underlying OS and the OS's default DNS resolver 65 | addr, err := net.ResolveTCPAddr("tcp", r.RemoteAddr) 66 | 67 | connInfo := acl.ConnInfo{ 68 | SrcIP: addr, 69 | Domain: r.Host, 70 | } 71 | acl.MakeDecision(&connInfo, c.ACL) 72 | if connInfo.Decision == acl.Reject || connInfo.Decision == acl.OriginIP || err != nil { 73 | l.Info().Str("src_ip", r.RemoteAddr).Msgf("rejected request") 74 | http.Error(w, "Could not reach origin server", 403) 75 | return 76 | } 77 | // if the URL starts with the public IP, it needs to be skipped to avoid loops 78 | // TODO: ipv6 should also be checked here 79 | if strings.HasPrefix(r.Host, c.PublicIPv4) { 80 | l.Warn().Msg("someone is requesting HTTP to sniproxy itself, ignoring...") 81 | http.Error(w, "Could not reach origin server", 404) 82 | return 83 | } 84 | 85 | l.Info().Str("method", r.Method).Str("host", r.Host).Str("url", r.URL.String()).Msg("request received") 86 | 87 | // Construct filtered header to send to origin server 88 | hh := http.Header{} 89 | for _, hk := range passthruRequestHeaderKeys { 90 | if hv, ok := r.Header[hk]; ok { 91 | hh[hk] = hv 92 | } 93 | } 94 | 95 | // Construct request to send to origin server 96 | rr := http.Request{ 97 | Method: r.Method, 98 | URL: r.URL, 99 | Header: hh, 100 | Body: r.Body, 101 | // TODO: Is this correct for a 0 value? 102 | // Perhaps a 0 may need to be reinterpreted as -1? 103 | ContentLength: r.ContentLength, 104 | Close: r.Close, 105 | } 106 | rr.URL.Scheme = "http" 107 | rr.URL.Host = r.Host 108 | 109 | // check to see if this host is listed to be processed, otherwise RESET 110 | // if !c.AllDomains && inDomainList(r.Host+".") { 111 | // http.Error(w, "Could not reach origin server", 403) 112 | // l.Warn().Msg("a client requested connection to " + r.Host + ", but it's not allowed as per configuration.. sending 403") 113 | // return 114 | // } 115 | 116 | // setting up this dialer will enable to use the upstream SOCKS5 if configured 117 | transport := http.Transport{ 118 | Dial: c.Dialer.Dial, 119 | } 120 | 121 | // Forward request to origin server 122 | resp, err := transport.RoundTrip(&rr) 123 | if err != nil { 124 | // TODO: Passthru more error information 125 | l.Error().Msg(err.Error()) 126 | return 127 | } 128 | defer resp.Body.Close() 129 | 130 | l.Info().Msgf("http response with status_code %s", resp.Status) 131 | 132 | // Transfer filtered header from origin server -> client 133 | respH := w.Header() 134 | for _, hk := range passthruResponseHeaderKeys { 135 | if hv, ok := resp.Header[hk]; ok { 136 | respH[hk] = hv 137 | } 138 | } 139 | c.ProxiedHTTP.Inc(1) 140 | w.WriteHeader(resp.StatusCode) 141 | 142 | // Transfer response from origin server -> client 143 | //TODO: error handling 144 | io.Copy(w, resp.Body) 145 | // if resp.ContentLength > 0 { 146 | // // (Ignore I/O errors, since there's nothing we can do) 147 | // io.CopyN(w, resp.Body, resp.ContentLength) 148 | // } else if resp.Close { // TODO: Is this condition right? 149 | // // Copy until EOF or some other error occurs 150 | // for { 151 | // if _, err := io.Copy(w, resp.Body); err != nil { 152 | // break 153 | // } 154 | // } 155 | // } 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /pkg/https.go: -------------------------------------------------------------------------------- 1 | package sniproxy 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "net" 7 | "net/netip" 8 | "strconv" 9 | 10 | "github.com/mosajjal/sniproxy/v2/pkg/acl" 11 | "github.com/rs/zerolog" 12 | "golang.org/x/net/proxy" 13 | ) 14 | 15 | // checks if the IP is the sniproxy itself 16 | func isSelf(c *Config, ip netip.Addr) bool { 17 | condition1 := ip.IsLoopback() || 18 | ip.IsPrivate() || ip == (netip.IPv4Unspecified()) 19 | 20 | if c.PublicIPv4 != "" { 21 | condition1 = condition1 || (ip == netip.MustParseAddr(c.PublicIPv4)) 22 | } 23 | if c.PublicIPv6 != "" { 24 | condition1 = condition1 || (ip == netip.MustParseAddr(c.PublicIPv6)) 25 | } 26 | if condition1 { 27 | return true 28 | } 29 | 30 | for _, v := range c.SourceAddr { 31 | if ip == v { 32 | return true 33 | } 34 | } 35 | return false 36 | } 37 | 38 | // handleTLS handles the incoming TLS connection 39 | func handleTLS(c *Config, conn net.Conn, l zerolog.Logger) error { 40 | c.RecievedHTTPS.Inc(1) 41 | 42 | incoming := make([]byte, 2048) // 2048 should be enough for a TLS Client Hello packet. But it could become problematic if tcp connection is fragmented or too big 43 | n, err := conn.Read(incoming) 44 | if err != nil { 45 | l.Err(err) 46 | return err 47 | } 48 | sni, err := GetHostname(incoming[:n]) 49 | if err != nil { 50 | l.Err(err) 51 | return err 52 | } 53 | if !isValidFQDN(sni) { 54 | l.Warn().Msgf("Invalid SNI: %s", sni) 55 | conn.Close() 56 | return nil 57 | } 58 | connInfo := acl.ConnInfo{ 59 | SrcIP: conn.RemoteAddr(), 60 | Domain: sni, 61 | } 62 | acl.MakeDecision(&connInfo, c.ACL) 63 | 64 | if connInfo.Decision == acl.Reject { 65 | l.Warn().Msgf("ACL rejection srcip=%s", conn.RemoteAddr().String()) 66 | conn.Close() 67 | return nil 68 | } 69 | // check SNI against domainlist for an extra layer of security 70 | if connInfo.Decision == acl.OriginIP { 71 | l.Warn().Str("sni", sni).Str("srcip", conn.RemoteAddr().String()).Msg("connection request rejected since it's not allowed as per ACL.. resetting TCP") 72 | conn.Close() 73 | return nil 74 | } 75 | rPort := getPortFromConn(conn) // by default, we'll use the listening port as the destination port 76 | var rAddr net.IP 77 | if connInfo.Decision == acl.Override { 78 | l.Debug().Msgf("overriding destination IP %s with %s as per override ACL", rAddr.String(), connInfo.DstIP.String()) 79 | rAddr = connInfo.DstIP.IP 80 | rPort = connInfo.DstIP.Port 81 | } else { 82 | rAddrTmp, err := c.DNSClient.lookupDomain(sni, c.PreferredVersion) 83 | if err != nil { 84 | l.Warn().Msg(err.Error()) 85 | return err 86 | } 87 | // TODO: handle timeout and context here 88 | if isSelf(c, rAddrTmp) && !c.AllowConnToLocal { 89 | l.Info().Msg("connection to private IP or self ignored") 90 | return nil 91 | } 92 | rAddr = rAddrTmp.AsSlice() 93 | } 94 | 95 | l.Debug().Str("sni", sni).Str("srcip", conn.RemoteAddr().String()).Str("dstip", rAddr.String()).Msg("connection request accepted") 96 | var target net.Conn 97 | // if the proxy is not set, or the destination IP is localhost, we'll use the OS's TCP stack and won't go through the SOCKS5 proxy 98 | if c.Dialer == proxy.Direct || rAddr.IsLoopback() { 99 | // with the manipulation of the soruce address, we can set the outbound interface 100 | srcAddr := net.TCPAddr{ 101 | IP: c.pickSrcAddr(c.PreferredVersion), 102 | Port: 0, 103 | } 104 | target, err = net.DialTCP("tcp", &srcAddr, &net.TCPAddr{IP: rAddr, Port: rPort}) 105 | if err != nil { 106 | l.Info().Msgf("could not connect to target with error: %s", err) 107 | conn.Close() 108 | return err 109 | } 110 | } else { 111 | target, err = c.Dialer.Dial("tcp", fmt.Sprintf("%s:%d", rAddr, rPort)) 112 | if err != nil { 113 | l.Info().Msgf("could not connect to target with error: %s", err) 114 | conn.Close() 115 | return err 116 | } 117 | } 118 | c.ProxiedHTTPS.Inc(1) 119 | target.Write(incoming[:n]) 120 | 121 | errc := make(chan error, 2) 122 | go proxyCopy(errc, conn, target) 123 | go proxyCopy(errc, target, conn) 124 | <-errc 125 | <-errc 126 | return nil 127 | } 128 | 129 | func proxyCopy(errc chan<- error, dst, src net.Conn) { 130 | defer src.Close() 131 | defer dst.Close() 132 | 133 | _, err := io.Copy(dst, src) 134 | errc <- err 135 | } 136 | 137 | func getPortFromConn(conn net.Conn) int { 138 | _, port, _ := net.SplitHostPort(conn.LocalAddr().String()) 139 | // convert the port string to its int format 140 | portnum, err := strconv.Atoi(port) 141 | if err != nil { 142 | return 0 143 | } 144 | return portnum 145 | } 146 | 147 | // RunHTTPS starts the HTTPS server on the configured bind 148 | // "bind" format is as ip:port 149 | func RunHTTPS(c *Config, bind string, l zerolog.Logger) { 150 | l = l.With().Str("service", "https").Str("listener", bind).Logger() 151 | if listener, err := net.Listen("tcp", bind); err != nil { 152 | l.Fatal().Msg(err.Error()) 153 | } else { 154 | l.Info().Msgf("listening https on %s", bind) 155 | defer listener.Close() 156 | for { 157 | if con, err := listener.Accept(); err != nil { 158 | l.Error().Msg(err.Error()) 159 | } else { 160 | go handleTLS(c, con, l) 161 | } 162 | } 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /pkg/https_sni.go: -------------------------------------------------------------------------------- 1 | /* {{{ Copyright 2017 Paul Tagliamonte 2 | * 3 | * Licensed under the Apache License, Version 2.0 (the "License"); 4 | * you may not use this file except in compliance with the License. 5 | * You may obtain a copy of the License at 6 | * 7 | * http://www.apache.org/licenses/LICENSE-2.0 8 | * 9 | * Unless required by applicable law or agreed to in writing, software 10 | * distributed under the License is distributed on an "AS IS" BASIS, 11 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | * See the License for the specific language governing permissions and 13 | * limitations under the License. }}} */ 14 | 15 | package sniproxy 16 | 17 | import ( 18 | "fmt" 19 | "regexp" 20 | ) 21 | 22 | var tlsHeaderLength = 5 23 | 24 | // GetHostname :This function is basically all most folks want to invoke out of this 25 | // jumble of bits. This will take an incoming TLS Client Hello (including 26 | // all the fuzzy bits at the beginning of it - fresh out of the socket) and 27 | // go ahead and give us the SNI Name they want. 28 | func GetHostname(data []byte) (string, error) { 29 | if len(data) == 0 || data[0] != 0x16 { 30 | return "", fmt.Errorf("Doesn't look like a TLS Client Hello") 31 | } 32 | 33 | extensions, err := getExtensionBlock(data) 34 | if err != nil { 35 | return "", err 36 | } 37 | sn, err := getSNBlock(extensions) 38 | if err != nil { 39 | return "", err 40 | } 41 | sni, err := getSNIBlock(sn) 42 | if err != nil { 43 | return "", err 44 | } 45 | return string(sni), nil 46 | } 47 | 48 | /* Return the length computed from the two octets starting at index */ 49 | func lengthFromData(data []byte, index int) int { 50 | b1 := int(data[index]) 51 | b2 := int(data[index+1]) 52 | 53 | return (b1 << 8) + b2 54 | } 55 | 56 | // getSNIBlock :Given a Server Name TLS Extension block, parse out and return the SNI 57 | // (Server Name Indication) payload 58 | func getSNIBlock(data []byte) ([]byte, error) { 59 | index := 0 60 | 61 | for { 62 | if index >= len(data) { 63 | break 64 | } 65 | length := lengthFromData(data, index) 66 | endIndex := index + 2 + length 67 | if data[index+2] == 0x00 { /* SNI */ 68 | sni := data[index+3:] 69 | sniLength := lengthFromData(sni, 0) 70 | return sni[2 : sniLength+2], nil 71 | } 72 | index = endIndex 73 | } 74 | return []byte{}, fmt.Errorf( 75 | "Finished parsing the SN block without finding an SNI", 76 | ) 77 | } 78 | 79 | // getSNBlock :Given a TLS Extensions data block, go ahead and find the SN block 80 | func getSNBlock(data []byte) ([]byte, error) { 81 | index := 0 82 | 83 | if len(data) < 2 { 84 | return []byte{}, fmt.Errorf("Not enough bytes to be an SN block") 85 | } 86 | 87 | extensionLength := lengthFromData(data, index) 88 | if extensionLength+2 > len(data) { 89 | return []byte{}, fmt.Errorf("Extension looks bonkers") 90 | } 91 | data = data[2 : extensionLength+2] 92 | 93 | for { 94 | if index+4 >= len(data) { 95 | break 96 | } 97 | length := lengthFromData(data, index+2) 98 | endIndex := index + 4 + length 99 | if data[index] == 0x00 && data[index+1] == 0x00 { 100 | return data[index+4 : endIndex], nil 101 | } 102 | 103 | index = endIndex 104 | } 105 | 106 | return []byte{}, fmt.Errorf( 107 | "Finished parsing the Extension block without finding an SN block", 108 | ) 109 | } 110 | 111 | // getExtensionBlock :Given a raw TLS Client Hello, go ahead and find all the Extensions 112 | func getExtensionBlock(data []byte) ([]byte, error) { 113 | /* data[0] - content type 114 | * data[1], data[2] - major/minor version 115 | * data[3], data[4] - total length 116 | * data[...38+5] - start of SessionID (length bit) 117 | * data[38+5] - length of SessionID 118 | */ 119 | var index = tlsHeaderLength + 38 120 | 121 | if len(data) <= index+1 { 122 | return []byte{}, fmt.Errorf("Not enough bits to be a Client Hello") 123 | } 124 | 125 | /* Index is at SessionID Length bit */ 126 | if newIndex := index + 1 + int(data[index]); (newIndex + 2) < len(data) { 127 | index = newIndex 128 | } else { 129 | return []byte{}, fmt.Errorf("Not enough bytes for the SessionID") 130 | } 131 | 132 | /* Index is at Cipher List Length bits */ 133 | if newIndex := (index + 2 + lengthFromData(data, index)); (newIndex + 1) < len(data) { 134 | index = newIndex 135 | } else { 136 | return []byte{}, fmt.Errorf("Not enough bytes for the Cipher List") 137 | } 138 | 139 | /* Index is now at the compression length bit */ 140 | if newIndex := index + 1 + int(data[index]); newIndex < len(data) { 141 | index = newIndex 142 | } else { 143 | return []byte{}, fmt.Errorf("Not enough bytes for the compression length") 144 | } 145 | 146 | /* Now we're at the Extension start */ 147 | if len(data[index:]) == 0 { 148 | return nil, fmt.Errorf("No extensions") 149 | } 150 | return data[index:], nil 151 | } 152 | 153 | // isValidFQDN validates if the given hostname is a valid FQDN 154 | func isValidFQDN(hostname string) bool { 155 | // Regular expression to match a valid FQDN 156 | var fqdnRegex = regexp.MustCompile(`^(?i:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+(?:[a-z]{2,})$`) 157 | return fqdnRegex.MatchString(hostname) 158 | } 159 | 160 | // vim: foldmethod=marker 161 | -------------------------------------------------------------------------------- /pkg/publicip.go: -------------------------------------------------------------------------------- 1 | package sniproxy 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "net" 7 | "net/http" 8 | "strings" 9 | ) 10 | 11 | // GetPublicIPv4 tries to determine the IPv4 address of the host 12 | // method 1: establish a udp connection to a known DNS server and see if we can get lucky by having a non-RFC1918 address on the interface 13 | // method 2: use a public HTTP service to get the public IP 14 | // note that neither of these methods are bulletproof, so there is always a chance that you need to enter the public IP manually 15 | func GetPublicIPv4() (string, error) { 16 | conn, err := net.Dial("udp", "8.8.8.8:53") 17 | if err != nil { 18 | return "", err 19 | } 20 | defer conn.Close() 21 | localAddr := conn.LocalAddr().String() 22 | idx := strings.LastIndex(localAddr, ":") 23 | ipaddr := localAddr[0:idx] 24 | if !net.ParseIP(ipaddr).IsPrivate() { 25 | return ipaddr, nil 26 | } 27 | externalIP := "" 28 | // trying to get the public IP from multiple sources to see if they match. 29 | resp, err := http.Get("https://4.ident.me") 30 | if err == nil { 31 | defer resp.Body.Close() 32 | body, err := io.ReadAll(resp.Body) 33 | if err == nil { 34 | externalIP = string(body) 35 | } 36 | 37 | if externalIP != "" { 38 | return externalIP, nil 39 | } 40 | } 41 | return "", fmt.Errorf("could not determine the public IPv4 address, please specify it in the configuration") 42 | } 43 | 44 | // cleanIPv6 removes the brackets from an IPv6 address 45 | func cleanIPv6(ip string) string { 46 | ip = strings.TrimPrefix(ip, "[") 47 | ip = strings.TrimSuffix(ip, "]") 48 | return ip 49 | } 50 | 51 | // GetPublicIPv6 tries to determine the IPv6 address of the host 52 | // method 1: establish a udp connection to a known DNS server and see if we can get lucky by having a non-RFC1918 address on the interface 53 | // method 2: use a public HTTP service to get the public IP 54 | // method 3: send a DNS query to OpenDNS to get the public IP. DISABLED 55 | // note that neither of these methods are bulletproof, so there is always a chance that you need to enter the public IP manually 56 | func GetPublicIPv6() (string, error) { 57 | conn, err := net.Dial("udp6", "[2001:4860:4860::8888]:53") 58 | if err != nil { 59 | return "", err 60 | } 61 | defer conn.Close() 62 | localAddr := conn.LocalAddr().String() 63 | idx := strings.LastIndex(localAddr, ":") 64 | ipaddr := localAddr[0:idx] 65 | if !net.ParseIP(ipaddr).IsPrivate() { 66 | return cleanIPv6(ipaddr), nil 67 | } 68 | externalIP := "" 69 | // trying to get the public IP from multiple sources to see if they match. 70 | resp, err := http.Get("https://6.ident.me") 71 | if err == nil { 72 | defer resp.Body.Close() 73 | body, err := io.ReadAll(resp.Body) 74 | if err == nil { 75 | externalIP = string(body) 76 | } 77 | 78 | // backup method of getting a public IP 79 | // if externalIP == "" { 80 | // // dig +short -6 myip.opendns.com aaaa @2620:0:ccc::2 81 | // dnsRes, err := c.DnsClient.PerformExternalAQuery("myip.opendns.com.", dns.TypeAAAA) 82 | // if err != nil { 83 | // return "", err 84 | // } 85 | // externalIP = dnsRes[0].(*dns.AAAA).AAAA.String() 86 | // } 87 | 88 | if externalIP != "" { 89 | return cleanIPv6(externalIP), nil 90 | } 91 | } 92 | return "", fmt.Errorf("could not determine the public IPv6 address, please specify it in the configuration") 93 | } 94 | --------------------------------------------------------------------------------