├── .gitignore ├── internal ├── app │ ├── app_test.go │ ├── config.go │ ├── log.go │ └── app.go ├── dns │ ├── providers.go │ ├── providers_test.go │ ├── static.go │ ├── doh.go │ └── dns.go ├── hosts │ └── hosts.go ├── api │ ├── api.go │ ├── stack.go │ └── request.go ├── proxy │ └── proxy.go ├── tls │ ├── sni.go │ ├── tls.go │ └── sni_test.go └── http │ └── http.go ├── go.mod ├── main.go ├── Dockerfile ├── LICENSE ├── scripts └── build.cmd ├── go.sum ├── .github └── workflows │ └── build.yml └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | .tmp/ 3 | 4 | pnproxy.yaml 5 | 6 | pnproxy_linux* 7 | pnproxy_mac* 8 | pnproxy_win* 9 | 10 | 0_test.go 11 | -------------------------------------------------------------------------------- /internal/app/app_test.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/require" 7 | ) 8 | 9 | func TestParseAction(t *testing.T) { 10 | name, params := ParseAction("static address 192.168.1.123") 11 | require.Equal(t, "static", name) 12 | require.Equal(t, map[string][]string{ 13 | "address": {"192.168.1.123"}, 14 | }, params) 15 | } 16 | -------------------------------------------------------------------------------- /internal/app/config.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/rs/zerolog/log" 7 | "gopkg.in/yaml.v3" 8 | ) 9 | 10 | func LoadConfig(v any) { 11 | if err := yaml.Unmarshal(config, v); err != nil { 12 | log.Error().Err(err).Caller().Send() 13 | } 14 | } 15 | 16 | var config []byte 17 | 18 | func initConfig(fileName string) { 19 | var err error 20 | if config, err = os.ReadFile(fileName); err != nil { 21 | log.Error().Err(err).Caller().Send() 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /internal/app/log.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/rs/zerolog" 7 | "github.com/rs/zerolog/log" 8 | ) 9 | 10 | func initLog() { 11 | var cfg struct { 12 | Log struct { 13 | Level string `yaml:"level"` 14 | } `yaml:"log"` 15 | } 16 | 17 | cfg.Log.Level = "info" 18 | 19 | LoadConfig(&cfg) 20 | 21 | lvl, err := zerolog.ParseLevel(cfg.Log.Level) 22 | if err != nil { 23 | log.Warn().Err(err).Caller().Send() 24 | return 25 | } 26 | 27 | log.Logger = log.Logger.Level(lvl) 28 | 29 | zerolog.TimeFieldFormat = time.RFC3339Nano 30 | } 31 | -------------------------------------------------------------------------------- /internal/dns/providers.go: -------------------------------------------------------------------------------- 1 | package dns 2 | 3 | import "net/url" 4 | 5 | // https://ru.wikipedia.org/wiki/DNS_%D0%BF%D0%BE%D0%B2%D0%B5%D1%80%D1%85_HTTPS 6 | // https://ru.wikipedia.org/wiki/DNS_%D0%BF%D0%BE%D0%B2%D0%B5%D1%80%D1%85_TLS 7 | var providers = map[string]string{ 8 | "cloudflare": "1.1.1.1", 9 | "google": "8.8.8.8", 10 | "quad9": "9.9.9.9", 11 | "opendns": "208.67.222.222", 12 | "yandex": "77.88.8.8", 13 | } 14 | 15 | func server(params url.Values) string { 16 | if params.Has("provider") { 17 | return providers[params.Get("provider")] 18 | } 19 | return params.Get("server") 20 | } 21 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/AlexxIT/pnproxy 2 | 3 | go 1.22 4 | 5 | require ( 6 | github.com/miekg/dns v1.1.61 7 | github.com/rs/zerolog v1.33.0 8 | github.com/stretchr/testify v1.9.0 9 | golang.org/x/net v0.26.0 10 | gopkg.in/yaml.v3 v3.0.1 11 | ) 12 | 13 | require ( 14 | github.com/davecgh/go-spew v1.1.1 // indirect 15 | github.com/mattn/go-colorable v0.1.13 // indirect 16 | github.com/mattn/go-isatty v0.0.19 // indirect 17 | github.com/pmezard/go-difflib v1.0.0 // indirect 18 | golang.org/x/mod v0.18.0 // indirect 19 | golang.org/x/sync v0.7.0 // indirect 20 | golang.org/x/sys v0.21.0 // indirect 21 | golang.org/x/text v0.16.0 // indirect 22 | golang.org/x/tools v0.22.0 // indirect 23 | ) 24 | -------------------------------------------------------------------------------- /internal/dns/providers_test.go: -------------------------------------------------------------------------------- 1 | package dns 2 | 3 | import ( 4 | "context" 5 | "net" 6 | "net/url" 7 | "testing" 8 | 9 | "github.com/stretchr/testify/require" 10 | ) 11 | 12 | func TestProviders(t *testing.T) { 13 | test := func(f func(params url.Values) dialFunc, provider string) { 14 | params := map[string][]string{"provider": {provider}} 15 | resolver := &net.Resolver{PreferGo: true, Dial: f(params)} 16 | addrs, err := resolver.LookupHost(context.Background(), "dns.google") 17 | require.Nil(t, err) 18 | require.Len(t, addrs, 4) 19 | } 20 | 21 | for provider := range providers { 22 | test(dialDNS, provider) 23 | test(dialDOH, provider) 24 | test(dialDOT, provider) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /internal/hosts/hosts.go: -------------------------------------------------------------------------------- 1 | package hosts 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/AlexxIT/pnproxy/internal/app" 7 | ) 8 | 9 | func Init() { 10 | var cfg struct { 11 | Hosts map[string]string `yaml:"hosts"` 12 | } 13 | 14 | app.LoadConfig(&cfg) 15 | 16 | for alias, aliases := range cfg.Hosts { 17 | hosts[alias] = Get(aliases) 18 | } 19 | } 20 | 21 | // Get convert list of aliases and domains to domains 22 | func Get(aliases string) (domains []string) { 23 | for _, alias := range strings.Fields(aliases) { 24 | if names, ok := hosts[alias]; ok { 25 | domains = append(domains, names...) 26 | } else { 27 | domains = append(domains, alias) 28 | } 29 | } 30 | return 31 | } 32 | 33 | var hosts = map[string][]string{} 34 | -------------------------------------------------------------------------------- /internal/dns/static.go: -------------------------------------------------------------------------------- 1 | package dns 2 | 3 | import ( 4 | "net" 5 | "strings" 6 | ) 7 | 8 | var staticSuffixes []string 9 | var staticIPs [][]net.IP 10 | 11 | func addStaticIP(name string, addrs []string) { 12 | var ips []net.IP 13 | for _, addr := range addrs { 14 | ips = append(ips, net.ParseIP(addr)) 15 | } 16 | // use suffix point, because all DNS queries has it 17 | // use prefix point, because support subdomains by default 18 | staticSuffixes = append(staticSuffixes, "."+name+".") 19 | staticIPs = append(staticIPs, ips) 20 | } 21 | 22 | func lookupStaticIP(name string) ([]net.IP, error) { 23 | name = "." + name 24 | for i, suffix := range staticSuffixes { 25 | if strings.HasSuffix(name, suffix) { 26 | return staticIPs[i], nil 27 | } 28 | } 29 | return nil, nil 30 | } 31 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | "os/signal" 6 | "syscall" 7 | 8 | "github.com/AlexxIT/pnproxy/internal/api" 9 | "github.com/AlexxIT/pnproxy/internal/app" 10 | "github.com/AlexxIT/pnproxy/internal/dns" 11 | "github.com/AlexxIT/pnproxy/internal/hosts" 12 | "github.com/AlexxIT/pnproxy/internal/http" 13 | "github.com/AlexxIT/pnproxy/internal/proxy" 14 | "github.com/AlexxIT/pnproxy/internal/tls" 15 | ) 16 | 17 | func main() { 18 | app.Version = "alpha" 19 | 20 | app.Init() // before all 21 | hosts.Init() // before others 22 | 23 | api.Init() 24 | dns.Init() 25 | tls.Init() 26 | http.Init() 27 | proxy.Init() 28 | 29 | sigs := make(chan os.Signal, 1) 30 | signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM) 31 | println("exit with signal:", (<-sigs).String()) 32 | } 33 | -------------------------------------------------------------------------------- /internal/app/app.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "flag" 5 | "net/url" 6 | "strings" 7 | ) 8 | 9 | var ( 10 | Version string 11 | Info = make(map[string]any) 12 | ) 13 | 14 | func Init() { 15 | var configPath string 16 | 17 | flag.StringVar(&configPath, "config", "pnproxy.yaml", "Path to config file") 18 | flag.Parse() 19 | 20 | initConfig(configPath) 21 | initLog() 22 | 23 | Info["version"] = Version 24 | Info["config_path"] = configPath 25 | } 26 | 27 | func ParseAction(raw string) (action string, params url.Values) { 28 | fields := strings.Fields(raw) 29 | 30 | if len(fields) > 0 { 31 | action = fields[0] 32 | params = url.Values{} 33 | for i := 1; i < len(fields); i += 2 { 34 | k := fields[i] 35 | v := fields[i+1] 36 | params[k] = append(params[k], v) 37 | } 38 | } 39 | 40 | return 41 | } 42 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # syntax=docker/dockerfile:labs 2 | 3 | ARG GO_VERSION="1.22" 4 | 5 | 6 | # 1. Build binary 7 | FROM --platform=$BUILDPLATFORM golang:${GO_VERSION}-alpine AS build 8 | ARG TARGETPLATFORM 9 | ARG TARGETOS 10 | ARG TARGETARCH 11 | 12 | ENV GOOS=${TARGETOS} 13 | ENV GOARCH=${TARGETARCH} 14 | 15 | WORKDIR /build 16 | 17 | # Cache dependencies 18 | COPY go.mod go.sum ./ 19 | RUN --mount=type=cache,target=/root/.cache/go-build go mod download 20 | 21 | COPY . . 22 | RUN --mount=type=cache,target=/root/.cache/go-build CGO_ENABLED=0 go build -ldflags "-s -w" -trimpath 23 | 24 | 25 | # 2. Final image 26 | FROM alpine 27 | 28 | # Install tini (for signal handling) 29 | RUN apk add --no-cache tini 30 | 31 | COPY --from=build /build/pnproxy /usr/local/bin/ 32 | 33 | ENTRYPOINT ["/sbin/tini", "--"] 34 | VOLUME /config 35 | WORKDIR /config 36 | 37 | CMD ["pnproxy", "-config", "/config/pnproxy.yaml"] 38 | -------------------------------------------------------------------------------- /internal/api/api.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | 7 | "github.com/AlexxIT/pnproxy/internal/app" 8 | "github.com/rs/zerolog/log" 9 | ) 10 | 11 | func Init() { 12 | var cfg struct { 13 | API struct { 14 | Listen string `yaml:"listen"` 15 | } `yaml:"api"` 16 | } 17 | 18 | app.LoadConfig(&cfg) 19 | 20 | if cfg.API.Listen == "" { 21 | return 22 | } 23 | 24 | http.HandleFunc("GET /api", api) 25 | http.HandleFunc("GET /api/request", apiRequest) 26 | http.HandleFunc("GET /api/stack", apiStack) 27 | 28 | go serve(cfg.API.Listen) 29 | } 30 | 31 | func serve(address string) { 32 | log.Info().Msgf("[api] listen=%s", address) 33 | 34 | srv := &http.Server{Addr: address} 35 | if err := srv.ListenAndServe(); err != nil { 36 | log.Error().Err(err).Caller().Send() 37 | } 38 | } 39 | 40 | func api(w http.ResponseWriter, r *http.Request) { 41 | w.Header().Set("Content-Type", "application/json") 42 | _ = json.NewEncoder(w).Encode(app.Info) 43 | } 44 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 AlexxIT 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | 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 FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /internal/proxy/proxy.go: -------------------------------------------------------------------------------- 1 | package proxy 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/AlexxIT/pnproxy/internal/app" 7 | ihttp "github.com/AlexxIT/pnproxy/internal/http" 8 | "github.com/AlexxIT/pnproxy/internal/tls" 9 | "github.com/rs/zerolog/log" 10 | ) 11 | 12 | func Init() { 13 | var cfg struct { 14 | Proxy struct { 15 | Listen string `yaml:"listen"` 16 | } `yaml:"proxy"` 17 | } 18 | 19 | app.LoadConfig(&cfg) 20 | 21 | if cfg.Proxy.Listen != "" { 22 | go serve(cfg.Proxy.Listen) 23 | } 24 | } 25 | 26 | func serve(address string) { 27 | log.Info().Msgf("[proxy] listen=%s", address) 28 | srv := &http.Server{ 29 | Addr: address, 30 | Handler: http.HandlerFunc(Handle), 31 | } 32 | if err := srv.ListenAndServe(); err != nil { 33 | log.Error().Err(err).Caller().Send() 34 | } 35 | } 36 | 37 | func Handle(w http.ResponseWriter, r *http.Request) { 38 | if r.Method == http.MethodConnect { 39 | src, _, err := w.(http.Hijacker).Hijack() 40 | if err != nil { 41 | log.Warn().Err(err).Caller().Send() 42 | return 43 | } 44 | if _, err = src.Write([]byte("HTTP/1.0 200 Connection established\r\n\r\n")); err != nil { 45 | log.Warn().Err(err).Caller().Send() 46 | return 47 | } 48 | tls.Handle(src) 49 | } else { 50 | r.RequestURI = "" 51 | 52 | ihttp.Handle(w, r) 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /internal/api/stack.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "net/http" 7 | "runtime" 8 | ) 9 | 10 | var stackSkip = [][]byte{ 11 | // main.go 12 | []byte("main.main()"), 13 | []byte("created by os/signal.Notify"), 14 | 15 | // api/stack.go 16 | []byte("github.com/AlexxIT/pnproxy/internal/api.apiStack"), 17 | 18 | // api/api.go 19 | []byte("created by github.com/AlexxIT/pnproxy/internal/api.Init"), 20 | []byte("created by net/http.(*connReader).startBackgroundRead"), 21 | []byte("created by net/http.(*Server).Serve"), // TODO: why two? 22 | 23 | []byte("created by github.com/AlexxIT/pnproxy/internal/dns.Init"), 24 | []byte("created by github.com/AlexxIT/pnproxy/internal/http.Init"), 25 | []byte("created by github.com/AlexxIT/pnproxy/internal/proxy.Init"), 26 | []byte("created by github.com/AlexxIT/pnproxy/internal/tls.Init"), 27 | } 28 | 29 | func apiStack(w http.ResponseWriter, r *http.Request) { 30 | sep := []byte("\n\n") 31 | buf := make([]byte, 65535) 32 | i := 0 33 | n := runtime.Stack(buf, true) 34 | skipped := 0 35 | for _, item := range bytes.Split(buf[:n], sep) { 36 | for _, skip := range stackSkip { 37 | if bytes.Contains(item, skip) { 38 | item = nil 39 | skipped++ 40 | break 41 | } 42 | } 43 | if item != nil { 44 | i += copy(buf[i:], item) 45 | i += copy(buf[i:], sep) 46 | } 47 | } 48 | i += copy(buf[i:], fmt.Sprintf( 49 | "Total: %d, Skipped: %d", runtime.NumGoroutine(), skipped), 50 | ) 51 | 52 | w.Header().Add("Content-Type", "text/plain") 53 | _, _ = w.Write(buf[:i]) 54 | } 55 | -------------------------------------------------------------------------------- /scripts/build.cmd: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | @SET GOOS=windows 4 | @SET GOARCH=amd64 5 | @SET FILENAME=pnproxy_win64.zip 6 | go build -ldflags "-s -w" -trimpath && 7z a -mx9 -sdel %FILENAME% pnproxy.exe 7 | 8 | @SET GOOS=windows 9 | @SET GOARCH=386 10 | @SET FILENAME=pnproxy_win32.zip 11 | go build -ldflags "-s -w" -trimpath && 7z a -mx9 -sdel %FILENAME% pnproxy.exe 12 | 13 | @SET GOOS=windows 14 | @SET GOARCH=arm64 15 | @SET FILENAME=pnproxy_win_arm64.zip 16 | go build -ldflags "-s -w" -trimpath && 7z a -mx9 -sdel %FILENAME% pnproxy.exe 17 | 18 | @SET GOOS=linux 19 | @SET GOARCH=amd64 20 | @SET FILENAME=pnproxy_linux_amd64 21 | go build -ldflags "-s -w" -trimpath -o %FILENAME% && upx %FILENAME% 22 | 23 | @SET GOOS=linux 24 | @SET GOARCH=386 25 | @SET FILENAME=pnproxy_linux_i386 26 | go build -ldflags "-s -w" -trimpath -o %FILENAME% && upx %FILENAME% 27 | 28 | @SET GOOS=linux 29 | @SET GOARCH=arm64 30 | @SET FILENAME=pnproxy_linux_arm64 31 | go build -ldflags "-s -w" -trimpath -o %FILENAME% && upx %FILENAME% 32 | 33 | @SET GOOS=linux 34 | @SET GOARCH=arm 35 | @SET GOARM=7 36 | @SET FILENAME=pnproxy_linux_arm 37 | go build -ldflags "-s -w" -trimpath -o %FILENAME% && upx %FILENAME% 38 | 39 | @SET GOOS=linux 40 | @SET GOARCH=arm 41 | @SET GOARM=6 42 | @SET FILENAME=pnproxy_linux_armv6 43 | go build -ldflags "-s -w" -trimpath -o %FILENAME% && upx %FILENAME% 44 | 45 | @SET GOOS=linux 46 | @SET GOARCH=mipsle 47 | @SET FILENAME=pnproxy_linux_mipsel 48 | go build -ldflags "-s -w" -trimpath -o %FILENAME% && upx %FILENAME% 49 | 50 | @SET GOOS=darwin 51 | @SET GOARCH=amd64 52 | @SET FILENAME=pnproxy_mac_amd64.zip 53 | go build -ldflags "-s -w" -trimpath && 7z a -mx9 -sdel %FILENAME% pnproxy 54 | 55 | @SET GOOS=darwin 56 | @SET GOARCH=arm64 57 | @SET FILENAME=pnproxy_mac_arm64.zip 58 | go build -ldflags "-s -w" -trimpath && 7z a -mx9 -sdel %FILENAME% pnproxy 59 | -------------------------------------------------------------------------------- /internal/dns/doh.go: -------------------------------------------------------------------------------- 1 | package dns 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "io" 7 | "net" 8 | "net/http" 9 | "sync" 10 | "time" 11 | ) 12 | 13 | type dohConn struct { 14 | server string 15 | deadline time.Time 16 | pool sync.Pool 17 | } 18 | 19 | func newDoHConn(server string) *dohConn { 20 | if net.ParseIP(server) != nil { 21 | server = "https://" + server + "/dns-query" 22 | } 23 | return &dohConn{server: server} 24 | } 25 | 26 | func (d *dohConn) Read(b []byte) (n int, err error) { 27 | req, ok := d.pool.Get().([]byte) 28 | if !ok { 29 | return 0, io.EOF 30 | } 31 | 32 | res, err := d.query(req) 33 | if err != nil { 34 | return 0, err 35 | } 36 | 37 | return copy(b, res), nil 38 | } 39 | 40 | func (d *dohConn) Write(b []byte) (n int, err error) { 41 | d.pool.Put(b) 42 | return len(b), nil 43 | } 44 | 45 | func (d *dohConn) Close() error { 46 | return nil 47 | } 48 | 49 | func (d *dohConn) LocalAddr() net.Addr { 50 | return nil 51 | } 52 | 53 | func (d *dohConn) RemoteAddr() net.Addr { 54 | return nil 55 | } 56 | 57 | func (d *dohConn) SetDeadline(t time.Time) error { 58 | d.deadline = t 59 | return nil 60 | } 61 | 62 | func (d *dohConn) SetReadDeadline(t time.Time) error { 63 | panic("not implemented") 64 | } 65 | 66 | func (d *dohConn) SetWriteDeadline(t time.Time) error { 67 | panic("not implemented") 68 | } 69 | 70 | func (d *dohConn) ReadFrom(p []byte) (n int, addr net.Addr, err error) { 71 | panic("not implemented") 72 | } 73 | 74 | func (d *dohConn) WriteTo(p []byte, addr net.Addr) (n int, err error) { 75 | panic("not implemented") 76 | } 77 | 78 | func (d *dohConn) query(b []byte) ([]byte, error) { 79 | deadline := d.deadline 80 | if deadline.IsZero() { 81 | deadline = time.Now().Add(5 * time.Second) 82 | } 83 | 84 | ctx, cancel := context.WithDeadline(context.Background(), deadline) 85 | defer cancel() 86 | 87 | // https://datatracker.ietf.org/doc/html/rfc8484 88 | req, err := http.NewRequestWithContext(ctx, "POST", d.server, bytes.NewReader(b)) 89 | if err != nil { 90 | return nil, err 91 | } 92 | 93 | req.Header.Set("Accept", "application/dns-message") 94 | req.Header.Set("Content-Type", "application/dns-message") 95 | 96 | res, err := http.DefaultClient.Do(req) 97 | if err != nil { 98 | return nil, err 99 | } 100 | 101 | return io.ReadAll(res.Body) 102 | } 103 | -------------------------------------------------------------------------------- /internal/api/request.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "net" 7 | "net/http" 8 | "net/http/httptest" 9 | "strings" 10 | "sync" 11 | "time" 12 | 13 | ihttp "github.com/AlexxIT/pnproxy/internal/http" 14 | "github.com/AlexxIT/pnproxy/internal/tls" 15 | ) 16 | 17 | func apiRequest(w http.ResponseWriter, r *http.Request) { 18 | urls := r.URL.Query()["url"] 19 | 20 | var wg sync.WaitGroup 21 | wg.Add(len(urls)) 22 | 23 | results := make([]any, len(urls)) 24 | 25 | for i, url := range urls { 26 | go func(i int, url string) { 27 | results[i] = request(url) 28 | wg.Done() 29 | }(i, url) 30 | } 31 | 32 | wg.Wait() 33 | 34 | w.Header().Add("Content-Type", "application/json") 35 | e := json.NewEncoder(w) 36 | e.SetIndent("", " ") 37 | _ = e.Encode(results) 38 | } 39 | 40 | func request(url string) any { 41 | if strings.Index(url, "://") < 0 { 42 | url = "https://" + url 43 | } 44 | 45 | result := &struct { 46 | URL string `json:"url"` 47 | Addrs []string `json:"dns_address,omitempty"` 48 | Proto string `json:"proto,omitempty"` 49 | StatusCode int `json:"status_code,omitempty"` 50 | Location string `json:"location,omitempty"` 51 | Error string `json:"error,omitempty"` 52 | }{ 53 | URL: url, 54 | } 55 | 56 | req, err := http.NewRequest("GET", url, nil) 57 | if err != nil { 58 | result.Error = err.Error() 59 | return result 60 | } 61 | 62 | result.Addrs, _ = net.LookupHost(req.URL.Host) 63 | 64 | var res *http.Response 65 | 66 | switch req.URL.Scheme { 67 | case "http": 68 | rec := httptest.NewRecorder() 69 | ihttp.Handle(rec, req) 70 | res = rec.Result() 71 | case "https": 72 | transport := &http.Transport{ 73 | DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { 74 | conn1, conn2 := net.Pipe() 75 | go tls.Handle(conn2) 76 | return conn1, nil 77 | }, 78 | ForceAttemptHTTP2: true, 79 | MaxIdleConns: 100, 80 | IdleConnTimeout: 90 * time.Second, 81 | TLSHandshakeTimeout: 10 * time.Second, 82 | ExpectContinueTimeout: 1 * time.Second, 83 | } 84 | res, err = transport.RoundTrip(req) 85 | if err != nil { 86 | result.Error = err.Error() 87 | return result 88 | } 89 | _ = res.Body.Close() 90 | } 91 | 92 | if res != nil { 93 | result.URL = res.Request.URL.String() 94 | result.Proto = res.Proto 95 | result.StatusCode = res.StatusCode 96 | if location := res.Header.Get("Location"); location != "" { 97 | result.Location = location 98 | } 99 | } 100 | 101 | return result 102 | } 103 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= 2 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 3 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 4 | github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= 5 | github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= 6 | github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 7 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 8 | github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= 9 | github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 10 | github.com/miekg/dns v1.1.61 h1:nLxbwF3XxhwVSm8g9Dghm9MHPaUZuqhPiGL+675ZmEs= 11 | github.com/miekg/dns v1.1.61/go.mod h1:mnAarhS3nWaW+NVP2wTkYVIZyHNJ098SJZUki3eykwQ= 12 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 13 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 14 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 15 | github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= 16 | github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8= 17 | github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss= 18 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= 19 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 20 | golang.org/x/mod v0.18.0 h1:5+9lSbEzPSdWkH32vYPBwEpX8KwDbM52Ud9xBUvNlb0= 21 | golang.org/x/mod v0.18.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= 22 | golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ= 23 | golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE= 24 | golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= 25 | golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 26 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 27 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 28 | golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 29 | golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws= 30 | golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 31 | golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= 32 | golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= 33 | golang.org/x/tools v0.22.0 h1:gqSGLZqv+AI9lIQzniJ0nZDRG5GBPsSi+DRNHWNz6yA= 34 | golang.org/x/tools v0.22.0/go.mod h1:aCwcsjqvq7Yqt6TNyX7QMU2enbQ/Gt0bo6krSeEri+c= 35 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 36 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 37 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 38 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 39 | -------------------------------------------------------------------------------- /internal/tls/sni.go: -------------------------------------------------------------------------------- 1 | package tls 2 | 3 | import ( 4 | "encoding/binary" 5 | "errors" 6 | "io" 7 | ) 8 | 9 | func readClientHello(r io.Reader) ([]byte, error) { 10 | buf := make([]byte, 16*1024) 11 | 12 | // read at least 5 bytes 13 | n1, err := io.ReadAtLeast(r, buf, 5) 14 | if err != nil { 15 | return nil, err 16 | } 17 | 18 | _ = buf[4] 19 | 20 | if buf[0] != 0x16 { 21 | return nil, errors.New("tls: not a handshake") 22 | } 23 | 24 | n := 5 + (int(buf[3])<<8 | int(buf[4])) 25 | if n1 == n { 26 | return buf[:n1], nil 27 | } 28 | 29 | if n1 > n { 30 | return nil, errors.New("tls: too big handshake") 31 | } 32 | 33 | n2, err := io.ReadAtLeast(r, buf[n1:], n-n1) 34 | if err != nil { 35 | return nil, err 36 | } 37 | 38 | return buf[:n1+n2], nil 39 | } 40 | 41 | func parseSNI(hello []byte) string { 42 | // https://datatracker.ietf.org/doc/html/rfc8446#page-27 43 | // byte - content type (0x16 - handshake) 44 | // uint16 - version 45 | // uint16 - packet length 46 | 47 | // byte - message type (0x01 - client hello) 48 | // uint24 - message length 49 | // uint16 - version 50 | // [32]byte - random 51 | 52 | helloLen := uint16(len(hello)) 53 | i := uint16(1 + 2 + 2 + 1 + 3 + 2 + 32) // session ID offset 54 | 55 | // byte - session ID length 56 | if i+1 > helloLen { 57 | return "" 58 | } 59 | sessionIDLen := uint16(hello[i]) 60 | i += 1 + sessionIDLen // cipher suites offset 61 | 62 | // uint16 - cipher suites length 63 | if i+2 > helloLen { 64 | return "" 65 | } 66 | cipherSuitesLen := binary.BigEndian.Uint16(hello[i:]) 67 | i += 2 + cipherSuitesLen // compression methods offset 68 | 69 | // byte - compression methods length 70 | if i+1 > helloLen { 71 | return "" 72 | } 73 | compressionMethodsLen := uint16(hello[i]) 74 | i += 1 + compressionMethodsLen // extensions offset 75 | 76 | // uint16 - extensions length 77 | if i+2 > helloLen { 78 | return "" 79 | } 80 | extensionsLen := binary.BigEndian.Uint16(hello[i:]) 81 | 82 | if i+2+extensionsLen > helloLen { 83 | return "" 84 | } 85 | return parseExtensions(hello[i+2 : i+2+extensionsLen]) 86 | } 87 | 88 | func parseExtensions(data []byte) string { 89 | dataLen := uint16(len(data)) 90 | 91 | for i := uint16(0); i < dataLen-4; { 92 | extType := binary.BigEndian.Uint16(data[i:]) 93 | extLen := binary.BigEndian.Uint16(data[i+2:]) 94 | i += 4 95 | 96 | if i+extLen > dataLen { 97 | break 98 | } 99 | 100 | const typeServerName = 0x00 101 | if extType == typeServerName { 102 | return parseSNIExtension(data[i : i+extLen]) 103 | } 104 | 105 | i += extLen 106 | } 107 | 108 | return "" 109 | } 110 | 111 | func parseSNIExtension(data []byte) string { 112 | dataLen := uint16(len(data)) 113 | 114 | if dataLen < 5 { 115 | return "" 116 | } 117 | 118 | listLen := binary.BigEndian.Uint16(data) 119 | if listLen != dataLen-2 { 120 | return "" 121 | } 122 | 123 | nameType := data[2] 124 | const typeHostName = 0x00 125 | if nameType != typeHostName { 126 | return "" 127 | } 128 | 129 | nameLen := binary.BigEndian.Uint16(data[3:]) 130 | if nameLen != dataLen-5 { 131 | return "" 132 | } 133 | 134 | return string(data[5 : 5+nameLen]) 135 | } 136 | -------------------------------------------------------------------------------- /internal/dns/dns.go: -------------------------------------------------------------------------------- 1 | package dns 2 | 3 | import ( 4 | "context" 5 | "crypto/tls" 6 | "net" 7 | "net/url" 8 | "time" 9 | 10 | "github.com/AlexxIT/pnproxy/internal/app" 11 | "github.com/AlexxIT/pnproxy/internal/hosts" 12 | "github.com/miekg/dns" 13 | "github.com/rs/zerolog/log" 14 | ) 15 | 16 | func Init() { 17 | var cfg struct { 18 | DNS struct { 19 | Listen string `yaml:"listen"` 20 | Rules []struct { 21 | Name string `yaml:"name"` 22 | Action string `yaml:"action"` 23 | } `yaml:"rules"` 24 | Default struct { 25 | Action string `yaml:"action"` 26 | } `yaml:"default"` 27 | } `yaml:"dns"` 28 | } 29 | 30 | app.LoadConfig(&cfg) 31 | 32 | for _, rule := range cfg.DNS.Rules { 33 | action, params := app.ParseAction(rule.Action) 34 | switch action { 35 | case "static": 36 | domains := hosts.Get(rule.Name) 37 | log.Debug().Msgf("[dns] static address for %s", domains) 38 | for _, domain := range domains { 39 | addStaticIP(domain, params["address"]) 40 | } 41 | default: 42 | log.Warn().Msgf("[dns] unknown action: %s", action) 43 | } 44 | } 45 | 46 | if dial := parseDefaultAction(cfg.DNS.Default.Action); dial != nil { 47 | net.DefaultResolver.PreferGo = true 48 | net.DefaultResolver.Dial = dial 49 | } 50 | 51 | if cfg.DNS.Listen != "" { 52 | go serve(cfg.DNS.Listen) 53 | } 54 | } 55 | 56 | func serve(address string) { 57 | log.Info().Msgf("[dns] listen=%s", address) 58 | server := &dns.Server{Addr: address, Net: "udp"} 59 | server.Handler = dns.HandlerFunc(func(wr dns.ResponseWriter, msg *dns.Msg) { 60 | m := &dns.Msg{} 61 | m.SetReply(msg) 62 | 63 | if msg.Opcode == dns.OpcodeQuery { 64 | parseQuery(m, wr.RemoteAddr()) 65 | } 66 | 67 | _ = wr.WriteMsg(m) 68 | }) 69 | 70 | if err := server.ListenAndServe(); err != nil { 71 | log.Error().Err(err).Caller().Send() 72 | } 73 | } 74 | 75 | func parseQuery(query *dns.Msg, remoteAddr net.Addr) { 76 | for _, question := range query.Question { 77 | if question.Qtype == dns.TypeA { 78 | ips, _ := lookupStaticIP(question.Name) 79 | 80 | if ips == nil { 81 | ips, _ = net.LookupIP(question.Name) 82 | } 83 | 84 | log.Trace().Msgf("[dns] query remote_addr=%s name=%s ips=%s", remoteAddr, question.Name, ips) 85 | 86 | for _, ip := range ips { 87 | if ip.To4() != nil { 88 | rr := &dns.A{ 89 | Hdr: dns.RR_Header{ 90 | Name: question.Name, 91 | Rrtype: question.Qtype, 92 | Class: question.Qclass, 93 | Ttl: 3600, 94 | }, 95 | A: ip, 96 | } 97 | query.Answer = append(query.Answer, rr) 98 | } 99 | } 100 | } 101 | } 102 | } 103 | 104 | type dialFunc func(ctx context.Context, network, address string) (net.Conn, error) 105 | 106 | func parseDefaultAction(raw string) dialFunc { 107 | if raw != "" { 108 | action, params := app.ParseAction(raw) 109 | switch action { 110 | case "dns": 111 | return dialDNS(params) 112 | case "doh": 113 | return dialDOH(params) 114 | case "dot": 115 | return dialDOT(params) 116 | } 117 | } 118 | return nil 119 | } 120 | 121 | func dialDNS(params url.Values) dialFunc { 122 | dialer := net.Dialer{Timeout: 5 * time.Second} 123 | address := server(params) + ":53" 124 | return func(ctx context.Context, _, _ string) (net.Conn, error) { 125 | return dialer.DialContext(ctx, "udp", address) 126 | } 127 | } 128 | 129 | func dialDOT(params url.Values) dialFunc { 130 | dialer := tls.Dialer{NetDialer: &net.Dialer{Timeout: 5 * time.Second}} 131 | address := server(params) + ":853" 132 | return func(ctx context.Context, _, _ string) (net.Conn, error) { 133 | return dialer.DialContext(ctx, "tcp", address) 134 | } 135 | } 136 | 137 | func dialDOH(params url.Values) dialFunc { 138 | conn := newDoHConn(server(params)) 139 | return func(ctx context.Context, network, address string) (net.Conn, error) { 140 | return conn, nil 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /internal/http/http.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import ( 4 | "io" 5 | "net/http" 6 | "net/url" 7 | "strconv" 8 | "strings" 9 | 10 | "github.com/AlexxIT/pnproxy/internal/app" 11 | "github.com/AlexxIT/pnproxy/internal/hosts" 12 | "github.com/rs/zerolog/log" 13 | ) 14 | 15 | func Init() { 16 | var cfg struct { 17 | HTTP struct { 18 | Listen string `yaml:"listen"` 19 | Rules []struct { 20 | Name string `yaml:"name"` 21 | Action string `yaml:"action"` 22 | } 23 | Default struct { 24 | Action string `yaml:"action"` 25 | } `yaml:"default"` 26 | } `yaml:"http"` 27 | } 28 | 29 | cfg.HTTP.Default.Action = "raw_pass" 30 | 31 | app.LoadConfig(&cfg) 32 | 33 | for _, rule := range cfg.HTTP.Rules { 34 | handler := parseAction(rule.Action) 35 | if handler == nil { 36 | log.Warn().Msgf("[http] wrong action: %s", rule.Action) 37 | continue 38 | } 39 | 40 | for _, name := range hosts.Get(rule.Name) { 41 | handlers["."+name] = handler 42 | } 43 | } 44 | 45 | defaultHandler = parseAction(cfg.HTTP.Default.Action) 46 | 47 | if cfg.HTTP.Listen != "" { 48 | go serve(cfg.HTTP.Listen) 49 | } 50 | } 51 | 52 | func Handle(w http.ResponseWriter, r *http.Request) { 53 | domain := r.Host 54 | if i := strings.IndexByte(r.Host, ':'); i > 0 { 55 | domain = domain[:i] 56 | } 57 | 58 | handler := findHandler(domain) 59 | if handler == nil { 60 | log.Trace().Msgf("[http] skip remote_addr=%s domain=%s", r.RemoteAddr, domain) 61 | return 62 | } 63 | 64 | log.Trace().Msgf("[http] open remote_addr=%s domain=%s", r.RemoteAddr, domain) 65 | 66 | handler(w, r) 67 | } 68 | 69 | var handlers = map[string]http.HandlerFunc{} 70 | var defaultHandler http.HandlerFunc 71 | 72 | func findHandler(domain string) http.HandlerFunc { 73 | domain = "." + domain 74 | for k, handler := range handlers { 75 | if strings.HasSuffix(domain, k) { 76 | return handler 77 | } 78 | } 79 | return defaultHandler 80 | } 81 | 82 | func serve(address string) { 83 | log.Info().Msgf("[http] listen=%s", address) 84 | srv := &http.Server{ 85 | Addr: address, 86 | Handler: http.HandlerFunc(Handle), 87 | } 88 | if err := srv.ListenAndServe(); err != nil { 89 | log.Error().Err(err).Caller().Send() 90 | } 91 | } 92 | 93 | func parseAction(raw string) http.HandlerFunc { 94 | if raw != "" { 95 | action, params := app.ParseAction(raw) 96 | switch action { 97 | case "redirect": 98 | return handleRedirect(params) 99 | case "raw_pass": 100 | return handleRaw(params) 101 | case "proxy_pass": 102 | return handleProxy(params) 103 | default: 104 | } 105 | } 106 | return nil 107 | } 108 | 109 | func handleRedirect(params url.Values) http.HandlerFunc { 110 | code := http.StatusTemporaryRedirect 111 | if params.Has("code") { 112 | code, _ = strconv.Atoi(params.Get("code")) 113 | } 114 | scheme := params.Get("scheme") 115 | 116 | return func(w http.ResponseWriter, r *http.Request) { 117 | if scheme != "" { 118 | r.URL.Scheme = scheme 119 | } 120 | w.Header().Add("Location", r.URL.String()) 121 | w.WriteHeader(code) 122 | } 123 | } 124 | 125 | func handleTransport(transport http.RoundTripper) http.HandlerFunc { 126 | return func(w http.ResponseWriter, r *http.Request) { 127 | r.URL.Scheme = "http" 128 | r.URL.Host = r.Host 129 | r.Header.Set("Host", r.Host) 130 | 131 | res, err := transport.RoundTrip(r) 132 | if err != nil { 133 | log.Warn().Err(err).Caller().Send() 134 | return 135 | } 136 | defer res.Body.Close() 137 | 138 | header := w.Header() 139 | for k, vv := range res.Header { 140 | for _, v := range vv { 141 | header.Add(k, v) 142 | } 143 | } 144 | 145 | w.WriteHeader(res.StatusCode) 146 | _, _ = io.Copy(w, res.Body) 147 | } 148 | } 149 | 150 | func handleRaw(params url.Values) http.HandlerFunc { 151 | return handleTransport(http.DefaultTransport) 152 | } 153 | 154 | func handleProxy(params url.Values) http.HandlerFunc { 155 | if !params.Has("host") { 156 | return nil 157 | } 158 | 159 | proxyURL := &url.URL{Host: params.Get("host")} 160 | if params.Has("type") { 161 | proxyURL.Scheme = params.Get("type") 162 | } else { 163 | proxyURL.Scheme = "http" 164 | } 165 | if params.Has("port") { 166 | proxyURL.Host += ":" + params.Get("port") 167 | } 168 | if params.Has("username") { 169 | if params.Has("password") { 170 | proxyURL.User = url.UserPassword(params.Get("username"), params.Get("password")) 171 | } else { 172 | proxyURL.User = url.User(params.Get("username")) 173 | } 174 | } 175 | 176 | transport := http.DefaultTransport.(*http.Transport).Clone() 177 | transport.Proxy = http.ProxyURL(proxyURL) 178 | 179 | return handleTransport(transport) 180 | } 181 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build and Push 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: 7 | - 'master' 8 | tags: 9 | - 'v*' 10 | 11 | jobs: 12 | build-binaries: 13 | name: Build binaries 14 | runs-on: ubuntu-latest 15 | env: { CGO_ENABLED: 0 } 16 | steps: 17 | - name: Checkout 18 | uses: actions/checkout@v4 19 | 20 | - name: Setup Go 21 | uses: actions/setup-go@v5 22 | with: { go-version: '1.22' } 23 | 24 | - name: Build pnproxy_win64 25 | env: { GOOS: windows, GOARCH: amd64 } 26 | run: go build -ldflags "-s -w" -trimpath 27 | - name: Upload pnproxy_win64 28 | uses: actions/upload-artifact@v4 29 | with: { name: pnproxy_win64, path: pnproxy.exe } 30 | 31 | - name: Build pnproxy_win32 32 | env: { GOOS: windows, GOARCH: 386 } 33 | run: go build -ldflags "-s -w" -trimpath 34 | - name: Upload pnproxy_win32 35 | uses: actions/upload-artifact@v4 36 | with: { name: pnproxy_win32, path: pnproxy.exe } 37 | 38 | - name: Build pnproxy_win_arm64 39 | env: { GOOS: windows, GOARCH: arm64 } 40 | run: go build -ldflags "-s -w" -trimpath 41 | - name: Upload pnproxy_win_arm64 42 | uses: actions/upload-artifact@v4 43 | with: { name: pnproxy_win_arm64, path: pnproxy.exe } 44 | 45 | - name: Build pnproxy_linux_amd64 46 | env: { GOOS: linux, GOARCH: amd64 } 47 | run: go build -ldflags "-s -w" -trimpath 48 | - name: Upload pnproxy_linux_amd64 49 | uses: actions/upload-artifact@v4 50 | with: { name: pnproxy_linux_amd64, path: pnproxy } 51 | 52 | - name: Build pnproxy_linux_i386 53 | env: { GOOS: linux, GOARCH: 386 } 54 | run: go build -ldflags "-s -w" -trimpath 55 | - name: Upload pnproxy_linux_i386 56 | uses: actions/upload-artifact@v4 57 | with: { name: pnproxy_linux_i386, path: pnproxy } 58 | 59 | - name: Build pnproxy_linux_arm64 60 | env: { GOOS: linux, GOARCH: arm64 } 61 | run: go build -ldflags "-s -w" -trimpath 62 | - name: Upload pnproxy_linux_arm64 63 | uses: actions/upload-artifact@v4 64 | with: { name: pnproxy_linux_arm64, path: pnproxy } 65 | 66 | - name: Build pnproxy_linux_arm 67 | env: { GOOS: linux, GOARCH: arm, GOARM: 7 } 68 | run: go build -ldflags "-s -w" -trimpath 69 | - name: Upload pnproxy_linux_arm 70 | uses: actions/upload-artifact@v4 71 | with: { name: pnproxy_linux_arm, path: pnproxy } 72 | 73 | - name: Build pnproxy_linux_armv6 74 | env: { GOOS: linux, GOARCH: arm, GOARM: 6 } 75 | run: go build -ldflags "-s -w" -trimpath 76 | - name: Upload pnproxy_linux_armv6 77 | uses: actions/upload-artifact@v4 78 | with: { name: pnproxy_linux_armv6, path: pnproxy } 79 | 80 | - name: Build pnproxy_linux_mipsel 81 | env: { GOOS: linux, GOARCH: mipsle } 82 | run: go build -ldflags "-s -w" -trimpath 83 | - name: Upload pnproxy_linux_mipsel 84 | uses: actions/upload-artifact@v4 85 | with: { name: pnproxy_linux_mipsel, path: pnproxy } 86 | 87 | - name: Build pnproxy_mac_amd64 88 | env: { GOOS: darwin, GOARCH: amd64 } 89 | run: go build -ldflags "-s -w" -trimpath 90 | - name: Upload pnproxy_mac_amd64 91 | uses: actions/upload-artifact@v4 92 | with: { name: pnproxy_mac_amd64, path: pnproxy } 93 | 94 | - name: Build pnproxy_mac_arm64 95 | env: { GOOS: darwin, GOARCH: arm64 } 96 | run: go build -ldflags "-s -w" -trimpath 97 | - name: Upload pnproxy_mac_arm64 98 | uses: actions/upload-artifact@v4 99 | with: { name: pnproxy_mac_arm64, path: pnproxy } 100 | 101 | - name: Build pnproxy_freebsd_amd64 102 | env: { GOOS: freebsd, GOARCH: amd64 } 103 | run: go build -ldflags "-s -w" -trimpath 104 | - name: Upload pnproxy_freebsd_amd64 105 | uses: actions/upload-artifact@v4 106 | with: { name: pnproxy_freebsd_amd64, path: pnproxy } 107 | 108 | - name: Build pnproxy_freebsd_arm64 109 | env: { GOOS: freebsd, GOARCH: arm64 } 110 | run: go build -ldflags "-s -w" -trimpath 111 | - name: Upload pnproxy_freebsd_arm64 112 | uses: actions/upload-artifact@v4 113 | with: { name: pnproxy_freebsd_arm64, path: pnproxy } 114 | 115 | docker-master: 116 | name: Build docker master 117 | runs-on: ubuntu-latest 118 | steps: 119 | - name: Checkout 120 | uses: actions/checkout@v4 121 | 122 | - name: Docker meta 123 | id: meta 124 | uses: docker/metadata-action@v5 125 | with: 126 | images: | 127 | ${{ github.repository }} 128 | ghcr.io/${{ github.repository }} 129 | tags: | 130 | type=ref,event=branch 131 | type=semver,pattern={{version}},enable=false 132 | type=match,pattern=v(.*),group=1 133 | 134 | - name: Set up QEMU 135 | uses: docker/setup-qemu-action@v3 136 | 137 | - name: Set up Docker Buildx 138 | uses: docker/setup-buildx-action@v3 139 | 140 | - name: Login to DockerHub 141 | if: github.event_name != 'pull_request' 142 | uses: docker/login-action@v3 143 | with: 144 | username: ${{ secrets.DOCKERHUB_USERNAME }} 145 | password: ${{ secrets.DOCKERHUB_TOKEN }} 146 | 147 | - name: Login to GitHub Container Registry 148 | if: github.event_name != 'pull_request' 149 | uses: docker/login-action@v3 150 | with: 151 | registry: ghcr.io 152 | username: ${{ github.actor }} 153 | password: ${{ secrets.GITHUB_TOKEN }} 154 | 155 | - name: Build and push 156 | uses: docker/build-push-action@v5 157 | with: 158 | context: . 159 | platforms: | 160 | linux/amd64 161 | linux/386 162 | linux/arm/v7 163 | linux/arm64/v8 164 | push: ${{ github.event_name != 'pull_request' }} 165 | tags: ${{ steps.meta.outputs.tags }} 166 | labels: ${{ steps.meta.outputs.labels }} 167 | cache-from: type=gha 168 | cache-to: type=gha,mode=max 169 | -------------------------------------------------------------------------------- /internal/tls/tls.go: -------------------------------------------------------------------------------- 1 | package tls 2 | 3 | import ( 4 | "encoding/base64" 5 | "io" 6 | "net" 7 | "net/url" 8 | "strings" 9 | "sync" 10 | "time" 11 | 12 | "github.com/AlexxIT/pnproxy/internal/app" 13 | "github.com/AlexxIT/pnproxy/internal/hosts" 14 | "github.com/rs/zerolog/log" 15 | "golang.org/x/net/proxy" 16 | ) 17 | 18 | func Init() { 19 | var cfg struct { 20 | TLS struct { 21 | Listen string `yaml:"listen"` 22 | Rules []struct { 23 | Name string `yaml:"name"` 24 | Action string `yaml:"action"` 25 | } 26 | Default struct { 27 | Action string `yaml:"action"` 28 | } `yaml:"default"` 29 | } `yaml:"tls"` 30 | } 31 | 32 | cfg.TLS.Default.Action = "raw_pass" 33 | 34 | app.LoadConfig(&cfg) 35 | 36 | for _, rule := range cfg.TLS.Rules { 37 | handler := parseAction(rule.Action) 38 | if handler == nil { 39 | log.Warn().Msgf("[tls] wrong action: %s", rule.Action) 40 | continue 41 | } 42 | 43 | for _, name := range hosts.Get(rule.Name) { 44 | handlers["."+name] = handler 45 | } 46 | } 47 | 48 | defaultHandler = parseAction(cfg.TLS.Default.Action) 49 | 50 | if cfg.TLS.Listen != "" { 51 | go serve(cfg.TLS.Listen) 52 | } 53 | } 54 | 55 | type handlerFunc func(src net.Conn, host string, hello []byte) 56 | 57 | var handlers = map[string]handlerFunc{} 58 | var defaultHandler handlerFunc 59 | 60 | func Handle(src net.Conn) { 61 | defer src.Close() 62 | 63 | remote := src.RemoteAddr().String() 64 | 65 | _ = src.SetReadDeadline(time.Now().Add(5 * time.Second)) 66 | 67 | hello, err := readClientHello(src) 68 | if err != nil { 69 | log.Warn().Err(err).Caller().Send() 70 | return 71 | } 72 | 73 | _ = src.SetReadDeadline(time.Time{}) 74 | 75 | domain := parseSNI(hello) 76 | if domain == "" { 77 | log.Warn().Msgf("[tls] skip empty domain remote_addr=%s data=%x", remote, hello) 78 | return 79 | } 80 | 81 | handler := findHandler(domain) 82 | if handler == nil { 83 | log.Trace().Msgf("[tls] skip remote_addr=%s domain=%s", remote, domain) 84 | return 85 | } 86 | 87 | log.Trace().Msgf("[tls] open remote_addr=%s domain=%s", remote, domain) 88 | 89 | handler(src, domain, hello) 90 | 91 | log.Trace().Msgf("[tls] close remote_addr=%s", remote) 92 | } 93 | 94 | func findHandler(domain string) handlerFunc { 95 | domain = "." + domain 96 | for k, handler := range handlers { 97 | if strings.HasSuffix(domain, k) { 98 | return handler 99 | } 100 | } 101 | return defaultHandler 102 | } 103 | 104 | func serve(address string) { 105 | log.Info().Msgf("[tls] listen=%s", address) 106 | 107 | ln, err := net.Listen("tcp", address) 108 | if err != nil { 109 | log.Error().Err(err).Caller().Send() 110 | return 111 | } 112 | 113 | for { 114 | conn, err := ln.Accept() 115 | if err != nil { 116 | log.Error().Err(err).Caller().Send() 117 | return 118 | } 119 | go Handle(conn) 120 | } 121 | } 122 | 123 | func parseAction(raw string) handlerFunc { 124 | if raw != "" { 125 | action, params := app.ParseAction(raw) 126 | switch action { 127 | case "raw_pass": 128 | return handleRaw(params) 129 | case "proxy_pass": 130 | return handleProxy(params) 131 | case "split_pass": 132 | return handleSplit(params) 133 | } 134 | } 135 | return nil 136 | } 137 | 138 | func handleRaw(params url.Values) handlerFunc { 139 | forceHost := params.Get("host") 140 | port := params.Get("port") 141 | if port == "" { 142 | port = "443" 143 | } 144 | 145 | dialer := net.Dialer{Timeout: 5 * time.Second} 146 | 147 | return func(src net.Conn, host string, hello []byte) { 148 | if forceHost != "" { 149 | host = forceHost 150 | } 151 | 152 | dst, err := dialer.Dial("tcp", host+":"+port) 153 | if err != nil { 154 | log.Warn().Err(err).Caller().Send() 155 | return 156 | } 157 | defer dst.Close() 158 | 159 | if _, err = dst.Write(hello); err != nil { 160 | log.Warn().Err(err).Caller().Send() 161 | return 162 | } 163 | 164 | ioCopy(dst, src) 165 | } 166 | } 167 | 168 | var splitRetry = map[string]byte{} 169 | var splitMu sync.Mutex 170 | 171 | func handleSplit(params url.Values) handlerFunc { 172 | return func(src net.Conn, host string, hello []byte) { 173 | splitMu.Lock() 174 | retry := splitRetry[host] 175 | splitMu.Unlock() 176 | for ; retry < 3; retry++ { 177 | if err := handleSplitRetry(src, host, hello, retry); err == nil { 178 | if retry > 0 { 179 | log.Debug().Msgf("[tcp] split ok host=%s retry=%d", host, retry) 180 | splitMu.Lock() 181 | splitRetry[host] = retry 182 | splitMu.Unlock() 183 | } 184 | return 185 | } 186 | } 187 | log.Warn().Msgf("[tcp] split fail host=%s", host) 188 | } 189 | } 190 | 191 | func handleSplitRetry(src net.Conn, host string, hello []byte, retry byte) error { 192 | dst, err := net.DialTimeout("tcp", host+":443", 5*time.Second) 193 | if err != nil { 194 | return err 195 | } 196 | defer dst.Close() 197 | 198 | k := 3 * time.Duration(retry) // 0, 3, 6 199 | 200 | delay := k * time.Millisecond // 0ms, 3ms, 6ms 201 | if err = writeSplit(dst, hello, delay); err != nil { 202 | return err 203 | } 204 | 205 | timeout := 2*time.Second + k*time.Second // 2s, 5s, 8s 206 | _ = dst.SetReadDeadline(time.Now().Add(timeout)) 207 | 208 | b, err := dst.Read(hello) 209 | if err != nil { 210 | return err 211 | } 212 | 213 | _ = dst.SetReadDeadline(time.Time{}) 214 | 215 | if _, err = src.Write(hello[:b]); err != nil { 216 | return nil 217 | } 218 | 219 | ioCopy(dst, src) 220 | 221 | return nil 222 | } 223 | 224 | func writeSplit(conn net.Conn, hello []byte, delay time.Duration) error { 225 | if delay == 0 { 226 | for _, b := range hello { 227 | if _, err := conn.Write([]byte{b}); err != nil { 228 | return err 229 | } 230 | } 231 | } else { 232 | t0 := time.Now() 233 | for i, b := range hello { 234 | if dt := t0.Add(time.Duration(i) * delay).Sub(time.Now()); dt > 0 { 235 | time.Sleep(dt) 236 | } 237 | if _, err := conn.Write([]byte{b}); err != nil { 238 | return err 239 | } 240 | } 241 | } 242 | return nil 243 | } 244 | 245 | func handleProxy(params url.Values) handlerFunc { 246 | if !params.Has("host") || !params.Has("port") { 247 | return nil 248 | } 249 | 250 | if params.Get("type") == "socks5" { 251 | return handleProxySOCKS5(params) 252 | } else { 253 | return handleProxyHTTP(params) 254 | } 255 | } 256 | 257 | func handleProxyHTTP(params url.Values) handlerFunc { 258 | address := params.Get("host") + ":" + params.Get("port") 259 | connect := ":443 HTTP/1.1\r\n" 260 | if params.Has("username") { 261 | auth := base64.StdEncoding.EncodeToString( 262 | []byte(params.Get("username") + ":" + params.Get("password")), 263 | ) 264 | connect += "Proxy-Authorization: Basic " + auth + "\r\n\r\n" 265 | } else { 266 | connect += "\r\n" 267 | } 268 | 269 | dialer := net.Dialer{Timeout: 5 * time.Second} 270 | 271 | return func(src net.Conn, host string, hello []byte) { 272 | dst, err := dialer.Dial("tcp", address) 273 | if err != nil { 274 | return 275 | } 276 | defer dst.Close() 277 | 278 | if _, err = dst.Write([]byte("CONNECT " + host + connect)); err != nil { 279 | log.Warn().Err(err).Caller().Send() 280 | return 281 | } 282 | 283 | b := make([]byte, 1024*4) 284 | if _, err = dst.Read(b); err != nil { 285 | return 286 | } 287 | 288 | if _, err = dst.Write(hello); err != nil { 289 | log.Warn().Err(err).Caller().Send() 290 | return 291 | } 292 | 293 | ioCopy(dst, src) 294 | } 295 | } 296 | 297 | func handleProxySOCKS5(params url.Values) handlerFunc { 298 | address := params.Get("host") + ":" + params.Get("port") 299 | 300 | var auth *proxy.Auth 301 | if params.Has("username") { 302 | auth = &proxy.Auth{ 303 | User: params.Get("username"), 304 | Password: params.Get("password"), 305 | } 306 | } 307 | 308 | dialer, err := proxy.SOCKS5("tcp", address, auth, nil) 309 | if err != nil { 310 | return nil 311 | } 312 | 313 | return func(src net.Conn, host string, hello []byte) { 314 | dst, err := dialer.Dial("tcp", host+":443") 315 | if err != nil { 316 | return 317 | } 318 | defer dst.Close() 319 | 320 | if _, err = dst.Write(hello); err != nil { 321 | log.Warn().Err(err).Caller().Send() 322 | return 323 | } 324 | 325 | ioCopy(dst, src) 326 | } 327 | } 328 | 329 | func ioCopy(dst, src net.Conn) { 330 | go func() { 331 | _, _ = io.Copy(dst, src) 332 | _ = dst.Close() 333 | }() 334 | 335 | _, _ = io.Copy(src, dst) 336 | _ = src.Close() 337 | } 338 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # pnproxy 2 | 3 | **pnproxy** - Plug and Proxy is a simple home proxy for managing Internet traffic. 4 | 5 | Features: 6 | 7 | - work on all devices in the local network without additional settings 8 | - proxy settings for selected sites only 9 | - ad blocking support (like AdGuard) 10 | 11 | Types: 12 | 13 | - DNS proxy 14 | - Reverse proxy for HTTP and TLS (level 4 proxy) 15 | - HTTP anonymous proxy 16 | 17 | ## Install 18 | 19 | - Binary - [nightly.link](https://nightly.link/AlexxIT/pnproxy/workflows/build/master) 20 | - Docker - [alexxit/pnproxy](https://hub.docker.com/r/alexxit/pnproxy) 21 | - Home Assistant Add-on - [alexxit/hassio-addons](https://github.com/AlexxIT/hassio-addons) 22 | 23 | ## Setup 24 | 25 | For example, you want to block ads and also forward all Twitter traffic through external proxy server. 26 | And want it to work on all home devices without additional configuration on each device. 27 | 28 | 1. Install pnproxy on any server in your home network (ex. IP: `192.168.1.123`). 29 | It is important that ports 53, 80 and 443 be free on this server. 30 | 2. Create `pnproxy.yaml` 31 | ```yaml 32 | hosts: 33 | adblock: doubleclick.net googlesyndication.com 34 | tunnel: twitter.com twimg.com t.co x.com 35 | 36 | dns: 37 | listen: ":53" 38 | rules: 39 | - name: adblock # name from hosts block 40 | action: static address 127.0.0.1 # block this sites 41 | - name: tunnel # name from hosts block 42 | action: static address 192.168.1.123 # redirect this sites to pnproxy 43 | default: 44 | action: dns server 8.8.8.8 # resolve DNS for all other sites 45 | 46 | http: 47 | listen: ":80" 48 | rules: 49 | - name: tunnel # name from hosts block 50 | action: redirect scheme https # redirect this sites from HTTP to TLS module 51 | default: 52 | action: raw_pass 53 | 54 | tls: 55 | listen: ":443" 56 | rules: 57 | - name: tunnel # name from hosts block 58 | action: proxy_pass host 123.123.123.123 port 3128 # forward this sites to external HTTP proxy 59 | default: 60 | action: raw_pass 61 | 62 | proxy: 63 | listen: ":8080" # optionally run local HTTP proxy 64 | 65 | log: 66 | level: trace # optionally increase log level (default - info) 67 | ``` 68 | 3. Setup DNS server for your home router to `192.168.1.123`. 69 | 70 | Optionally, instead of step 3, you can verify that everything works by configuring an HTTP proxy to `192.168.1.123:8080` on your PC or mobile device. 71 | 72 | ## Configuration 73 | 74 | By default, the app looks for the `pnproxy.yaml` file in the current working directory. 75 | 76 | ```shell 77 | pnproxy -config /config/pnproxy.yaml 78 | ``` 79 | 80 | By default all modules disabled and don't listen any ports. 81 | 82 | ## Module: Hosts 83 | 84 | Store lists of site domains for use in other modules. 85 | 86 | - Name comparison includes all subdomains, you don't need to specify them separately! 87 | - Names can be written with spaces or line breaks. Follow [YAML syntax](https://yaml-multiline.info/). 88 | 89 | ```yaml 90 | hosts: 91 | list1: site1.com site2.com site3.net 92 | list2: | 93 | site1.com static.site1.cc 94 | site2.com cdnsite2.com 95 | site3.in site3.com site3.co.uk 96 | ``` 97 | 98 | ## Module: DNS 99 | 100 | Run DNS server and act as DNS proxy. 101 | 102 | - Can protect from MITM DNS attack using [DNS over TLS](https://en.wikipedia.org/wiki/DNS_over_TLS) or [DNS over HTTPS](https://en.wikipedia.org/wiki/DNS_over_HTTPS) 103 | - Can work as AdBlock like [AdGuard](https://adguard.com/) 104 | 105 | Enable server: 106 | 107 | ```yaml 108 | dns: 109 | listen: ":53" 110 | ``` 111 | 112 | Rules action supports setting `static address` only: 113 | 114 | - Useful for ad blocking. 115 | - Useful for routing some sites traffic through pnproxy. 116 | 117 | ```yaml 118 | dns: 119 | rules: 120 | - name: adblocklist 121 | action: static address 127.0.0.1 122 | - name: list1 list2 site4.com site5.net 123 | action: static address 192.168.1.123 124 | ``` 125 | 126 | Default action supports [DNS](https://en.wikipedia.org/wiki/Domain_Name_System), [DOT](https://en.wikipedia.org/wiki/DNS_over_TLS) and [DOH](https://en.wikipedia.org/wiki/DNS_over_HTTPS) upstream: 127 | 128 | - Important to use server IP-address, instead of a domain name 129 | 130 | ```yaml 131 | dns: 132 | default: 133 | # action - dns or dot or doh 134 | action: dns server 8.8.8.8 135 | ``` 136 | 137 | Support build-in providers - `cloudflare`, `google`, `quad9`, `opendns`, `yandex`: 138 | 139 | - all this providers support DNS, DOH and DOT technologies. 140 | 141 | ```yaml 142 | dns: 143 | default: 144 | action: dot provider google 145 | ``` 146 | 147 | Total config: 148 | 149 | ```yaml 150 | dns: 151 | listen: ":53" 152 | rules: 153 | - name: adblocklist 154 | action: static address 127.0.0.1 155 | - name: list1 list2 site4.com site5.net 156 | action: static address 192.168.1.123 157 | default: 158 | action: doh provider cloudflare 159 | ``` 160 | 161 | ## Module: HTTP 162 | 163 | Run HTTP server and act as reverse proxy. 164 | 165 | Enable server: 166 | 167 | ```yaml 168 | http: 169 | listen: ":80" 170 | ``` 171 | 172 | Rules action supports setting `redirect scheme https` with optional code: 173 | 174 | - Useful for redirect all sites traffic to TLS module. 175 | 176 | ```yaml 177 | http: 178 | rules: 179 | - name: list1 list2 site4.com site5.net 180 | # code - any number (default - 307) 181 | action: redirect scheme https 182 | ``` 183 | 184 | Rules action supports setting `raw_pass`: 185 | 186 | ```yaml 187 | http: 188 | rules: 189 | - name: list1 list2 site4.com site5.net 190 | action: raw_pass 191 | ``` 192 | 193 | Rules action supports setting `proxy_pass`: 194 | 195 | - Useful for passing all sites traffic to additional local or remote proxy. 196 | 197 | ```yaml 198 | http: 199 | rules: 200 | - name: list1 list2 site4.com site5.net 201 | # host and port - mandatory 202 | # username and password - optional 203 | # type - socks5 (default - http) 204 | action: proxy_pass host 123.123.123.123 port 3128 username user1 password pasw1 205 | ``` 206 | 207 | Default action support all rules actions: 208 | 209 | ```yaml 210 | http: 211 | default: 212 | action: raw_pass 213 | ``` 214 | 215 | ## Module: TLS 216 | 217 | Run TCP server and act as Layer 4 reverse proxy. 218 | 219 | Enable server: 220 | 221 | ```yaml 222 | tls: 223 | listen: ":443" 224 | ``` 225 | 226 | Rules action supports setting `raw_pass`: 227 | 228 | - Useful for forward HTTPS traffic to another reverse proxies with custom port. 229 | 230 | ```yaml 231 | tls: 232 | rules: 233 | - name: list1 list2 site4.com site5.net 234 | # host - optional rewrite connection IP-address 235 | # port - optional rewrite connection port 236 | action: raw_pass host 123.123.123.123 port 10443 237 | ``` 238 | 239 | Rules action supports setting `proxy_pass`: 240 | 241 | - Useful for passing all sites traffic to additional local or remote proxy. 242 | 243 | ```yaml 244 | tls: 245 | rules: 246 | - name: list1 list2 site4.com site5.net 247 | # host and port - mandatory 248 | # username and password - optional 249 | # type - socks5 (default - http) 250 | action: proxy_pass host 123.123.123.123 port 3128 username user1 password pasw1 251 | ``` 252 | 253 | Rules action supports setting `split_pass`: 254 | 255 | - Can try to protect from hardware MITM HTTPS attack. 256 | 257 | ```yaml 258 | tls: 259 | rules: 260 | - name: list1 list2 site4.com site5.net 261 | action: split_pass 262 | ``` 263 | 264 | Default action support all rules actions: 265 | 266 | ```yaml 267 | tls: 268 | default: 269 | action: raw_pass 270 | ``` 271 | 272 | ## Module: Proxy 273 | 274 | Run HTTP proxy server. This module does not have its own rules. It uses the HTTP and TLS module rules. 275 | You can choose not to run DNS, HTTP, and TLS servers and use pnproxy only as HTTP proxy server. 276 | 277 | Enable server: 278 | 279 | ```yaml 280 | proxy: 281 | listen: ":8080" 282 | ``` 283 | 284 | ## Tips and Tricks 285 | 286 | **Mikrotik DNS fail over script** 287 | 288 | - Add as System > Scheduler > Interval `00:01:00` 289 | 290 | ``` 291 | :global server "192.168.1.123" 292 | 293 | :do { 294 | :resolve google.com server $server 295 | } on-error={ 296 | :global server "8.8.8.8" 297 | } 298 | 299 | :if ([/ip dns get servers] != $server) do={ 300 | /ip dns set servers=$server 301 | } 302 | ``` 303 | 304 | ## Known bugs 305 | 306 | In rare cases, due to [HTTP/2 connection coalescing](https://blog.cloudflare.com/connection-coalescing-experiments) technology, some site may not work properly when using a TCP/TLS Layer 4 proxy. In HTTP proxy mode everything works fine. Everything works fine in Safari browser (it doesn't support this technology). In Firefox, this feature can be disabled - `network.http.http2.coalesce-hostnames`. 307 | -------------------------------------------------------------------------------- /internal/tls/sni_test.go: -------------------------------------------------------------------------------- 1 | package tls 2 | 3 | import ( 4 | "bytes" 5 | "encoding/hex" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | func Test_parseSNI(t *testing.T) { 12 | type args struct { 13 | hello string 14 | } 15 | tests := []struct { 16 | name string 17 | args args 18 | wantSni string 19 | }{ 20 | { 21 | name: "Valid SNI", 22 | args: args{hello: "1603010200010001fc0303802dfbd5b002be713804193f683bbbf9a1c9673993c5561eb0eecf1e0ce387b9200b9c0335c7752ad641ee7bbb8037c7534d8b4c001cda75c6b788a53dc4c47b8c002a3a3a130113021303c02cc02bcca9c030c02fcca8c00ac009c014c013009d009c0035002fc008c012000a010001895a5a00000000000d000b000008686162722e636f6d00170000ff01000100000a000c000ababa001d001700180019000b000201000010000b000908687474702f312e31000500050100000000000d0018001604030804040105030203080508050501080606010201001200000033002b0029baba000100001d0020365e72276a10052ecc8e4712f6da8ce322946757a4a3c2377c211935b447e861002d00020101002b000b0a0a0a0304030303020301001b00030200011a1a000100001500c9000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"}, 23 | wantSni: "habr.com", 24 | }, 25 | { 26 | name: "Valid SNI", 27 | args: args{hello: "1603010200010001fc03034ce96db02d4d09fa225810d7a323094f7d0a7be4263418bca574b5eb8695021e2056bd61e07b47dfad4e3c7a5b76b1bd4a4c2ef8b0b8fe2dc478ac7cdc2b1256dd002a7a7a130113021303c02cc02bcca9c030c02fcca8c00ac009c014c013009d009c0035002fc008c012000a010001892a2a000000000013001100000e7777772e676f6f676c652e636f6d00170000ff01000100000a000c000afafa001d001700180019000b000201000010000e000c02683208687474702f312e31000500050100000000000d0018001604030804040105030203080508050501080606010201001200000033002b0029fafa000100001d00203a76006087284aea210d83eece81500929319cae5b679e9789057f7abd19bb4e002d00020101002b000b0a3a3a0304030303020301001b0003020001aaaa000100001500c0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"}, 28 | wantSni: "www.google.com", 29 | }, 30 | { 31 | name: "No SNI", 32 | args: args{hello: "16030300700200006c03035d27d80a8bd924a6afbbf9c29ca3f1135f2cc366c8b014d8a74262561dc568e320f93defe903c0f2ab7f539aabbf6b96c5ab039c7b6d2390242e6e610bf4db95e7c030000024ff0100010000000000000b0004030001020010000b000908687474702f312e31001700001603030e300b000e2c000e2900066b308206673082054fa003020102020c787015fec0a8f90827a19231300d06092a864886f70d01010b05003050310b300906035504061302424531193017060355040a1310476c6f62616c5369676e206e762d7361312630240603550403131d476c6f62616c5369676e20525341204f562053534c2043412032303138301e170d3234303131393039313033305a170d3235303231393039313032395a307e310b3009060355040613025255310f300d060355040813064d6f73636f77310f300d060355040713064d6f73636f7731363034060355040a132d4d6f62696c652054656c6553797374656d73205075626c6963204a6f696e742d53746f636b20436f6d70616e793115301306035504030c0c2a2e7274622e6d74732e727530820122300d06092a864886f70d01010105000382010f003082010a0282010100ada2dc5c166a0c39f6067924958ab90153256fb1957a7df3709edf92a1332d87eaa1b3d60204e399bdb556e5da7d4fa144e26e6bf79128513c2ca57c0911978cd00f3f1fcd2400ec6865998a2faa90ff1d2b7dcad63d55f6faa4110091ee49957c900a45839ceef73dcadb0fa88ae3e133fd771eb86a7ee199ee0a7a0983ab73c98c0fcedfba5a1d6c0e909dddc0934e60422c42bc2bf31f0c0dd7a929fe7e5d5c3c2c2b438a42ca13770ae61277a9e46749682068044f000ff8f6b2260bc1a594c3f6c65c3bba9dd37f0584487816e5b8496af3a71a5c3b700b654c5607da76df8109e8d53483ea130ba2e48787cec4763347f2e62f0815c4843d4770b786c30203010001a38203113082030d300e0603551d0f0101ff0404030205a0300c0603551d130101ff0402300030818e06082b06010505070101048181307f304406082b060105050730028638687474703a2f2f7365637572652e676c6f62616c7369676e2e636f6d2f6361636572742f67737273616f7673736c6361323031382e637274303706082b06010505073001862b687474703a2f2f6f6373702e676c6f62616c7369676e2e636f6d2f67737273616f7673736c63613230313830560603551d20044f304d304106092b06010401a03201143034303206082b06010505070201162668747470733a2f2f7777772e676c6f62616c7369676e2e636f6d2f7265706f7369746f72792f3008060667810c01020230230603551d11041c301a820c2a2e7274622e6d74732e7275820a7274622e6d74732e7275301d0603551d250416301406082b0601050507030106082b06010505070302301f0603551d23041830168014f8ef7ff2cd7867a8de6f8f248d88f1870302b3eb301d0603551d0e04160414203dec32cd25741275c9ab6eeef078123ff2dba63082017e060a2b06010401d6790204020482016e0482016a0168007600a2e30ae445efbdad9b7e38ed47677753d7825b8494d72b5e1b2cc4b950a447e70000018d20fc803b0000040300473045022100b2de06918ece4b18d7f935db1161cb29459e2769facfc0e86898efe1c1d6c93902200e0afe7cf7c47ec6f04e1c5643bc855927f55b43ae51d8b0163d1971821cc827007600e6d2316340778cc1104106d771b9cec1d240f6968486fbba87321dfd1e378e500000018d20fc803a0000040300473045022100b07ba64e27a61b25f4924f601763c6036012193e0b366745990b5f95a62c92d9022062b20e60075517f8bdffd0bfa0638c8b5e9b28e72cf9b7749d799a47a02f4b6d0076004e75a3275c9a10c3385b6cd4df3f52eb1df0e08e1b8d69c0b1fa64b1629a39df0000018d20fc800f00000403004730450221009e5002d8161c617f71d75fdb085ded"}, 33 | wantSni: "", 34 | }, 35 | } 36 | for _, tt := range tests { 37 | t.Run(tt.name, func(t *testing.T) { 38 | b, err := hex.DecodeString(tt.args.hello) 39 | require.Nil(t, err) 40 | 41 | clientHello, err := readClientHello(bytes.NewReader(b)) 42 | if tt.wantSni != "" { 43 | require.Nil(t, err) 44 | } 45 | 46 | gotSni := parseSNI(clientHello) 47 | require.Equal(t, gotSni, tt.wantSni) 48 | }) 49 | } 50 | } 51 | 52 | func Benchmark_parseSNI(b *testing.B) { 53 | benchmarks := []struct { 54 | name string 55 | hello string 56 | }{ 57 | { 58 | name: "Valid SNI", 59 | hello: "1603010200010001fc0303802dfbd5b002be713804193f683bbbf9a1c9673993c5561eb0eecf1e0ce387b9200b9c0335c7752ad641ee7bbb8037c7534d8b4c001cda75c6b788a53dc4c47b8c002a3a3a130113021303c02cc02bcca9c030c02fcca8c00ac009c014c013009d009c0035002fc008c012000a010001895a5a00000000000d000b000008686162722e636f6d00170000ff01000100000a000c000ababa001d001700180019000b000201000010000b000908687474702f312e31000500050100000000000d0018001604030804040105030203080508050501080606010201001200000033002b0029baba000100001d0020365e72276a10052ecc8e4712f6da8ce322946757a4a3c2377c211935b447e861002d00020101002b000b0a0a0a0304030303020301001b00030200011a1a000100001500c9000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", 60 | }, 61 | { 62 | name: "Valid SNI", 63 | hello: "1603010200010001fc03034ce96db02d4d09fa225810d7a323094f7d0a7be4263418bca574b5eb8695021e2056bd61e07b47dfad4e3c7a5b76b1bd4a4c2ef8b0b8fe2dc478ac7cdc2b1256dd002a7a7a130113021303c02cc02bcca9c030c02fcca8c00ac009c014c013009d009c0035002fc008c012000a010001892a2a000000000013001100000e7777772e676f6f676c652e636f6d00170000ff01000100000a000c000afafa001d001700180019000b000201000010000e000c02683208687474702f312e31000500050100000000000d0018001604030804040105030203080508050501080606010201001200000033002b0029fafa000100001d00203a76006087284aea210d83eece81500929319cae5b679e9789057f7abd19bb4e002d00020101002b000b0a3a3a0304030303020301001b0003020001aaaa000100001500c0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", 64 | }, 65 | { 66 | name: "No SNI", 67 | hello: "16030300700200006c03035d27d80a8bd924a6afbbf9c29ca3f1135f2cc366c8b014d8a74262561dc568e320f93defe903c0f2ab7f539aabbf6b96c5ab039c7b6d2390242e6e610bf4db95e7c030000024ff0100010000000000000b0004030001020010000b000908687474702f312e31001700001603030e300b000e2c000e2900066b308206673082054fa003020102020c787015fec0a8f90827a19231300d06092a864886f70d01010b05003050310b300906035504061302424531193017060355040a1310476c6f62616c5369676e206e762d7361312630240603550403131d476c6f62616c5369676e20525341204f562053534c2043412032303138301e170d3234303131393039313033305a170d3235303231393039313032395a307e310b3009060355040613025255310f300d060355040813064d6f73636f77310f300d060355040713064d6f73636f7731363034060355040a132d4d6f62696c652054656c6553797374656d73205075626c6963204a6f696e742d53746f636b20436f6d70616e793115301306035504030c0c2a2e7274622e6d74732e727530820122300d06092a864886f70d01010105000382010f003082010a0282010100ada2dc5c166a0c39f6067924958ab90153256fb1957a7df3709edf92a1332d87eaa1b3d60204e399bdb556e5da7d4fa144e26e6bf79128513c2ca57c0911978cd00f3f1fcd2400ec6865998a2faa90ff1d2b7dcad63d55f6faa4110091ee49957c900a45839ceef73dcadb0fa88ae3e133fd771eb86a7ee199ee0a7a0983ab73c98c0fcedfba5a1d6c0e909dddc0934e60422c42bc2bf31f0c0dd7a929fe7e5d5c3c2c2b438a42ca13770ae61277a9e46749682068044f000ff8f6b2260bc1a594c3f6c65c3bba9dd37f0584487816e5b8496af3a71a5c3b700b654c5607da76df8109e8d53483ea130ba2e48787cec4763347f2e62f0815c4843d4770b786c30203010001a38203113082030d300e0603551d0f0101ff0404030205a0300c0603551d130101ff0402300030818e06082b06010505070101048181307f304406082b060105050730028638687474703a2f2f7365637572652e676c6f62616c7369676e2e636f6d2f6361636572742f67737273616f7673736c6361323031382e637274303706082b06010505073001862b687474703a2f2f6f6373702e676c6f62616c7369676e2e636f6d2f67737273616f7673736c63613230313830560603551d20044f304d304106092b06010401a03201143034303206082b06010505070201162668747470733a2f2f7777772e676c6f62616c7369676e2e636f6d2f7265706f7369746f72792f3008060667810c01020230230603551d11041c301a820c2a2e7274622e6d74732e7275820a7274622e6d74732e7275301d0603551d250416301406082b0601050507030106082b06010505070302301f0603551d23041830168014f8ef7ff2cd7867a8de6f8f248d88f1870302b3eb301d0603551d0e04160414203dec32cd25741275c9ab6eeef078123ff2dba63082017e060a2b06010401d6790204020482016e0482016a0168007600a2e30ae445efbdad9b7e38ed47677753d7825b8494d72b5e1b2cc4b950a447e70000018d20fc803b0000040300473045022100b2de06918ece4b18d7f935db1161cb29459e2769facfc0e86898efe1c1d6c93902200e0afe7cf7c47ec6f04e1c5643bc855927f55b43ae51d8b0163d1971821cc827007600e6d2316340778cc1104106d771b9cec1d240f6968486fbba87321dfd1e378e500000018d20fc803a0000040300473045022100b07ba64e27a61b25f4924f601763c6036012193e0b366745990b5f95a62c92d9022062b20e60075517f8bdffd0bfa0638c8b5e9b28e72cf9b7749d799a47a02f4b6d0076004e75a3275c9a10c3385b6cd4df3f52eb1df0e08e1b8d69c0b1fa64b1629a39df0000018d20fc800f00000403004730450221009e5002d8161c617f71d75fdb085ded", 68 | }, 69 | } 70 | 71 | for _, bm := range benchmarks { 72 | b.Run(bm.name, func(b *testing.B) { 73 | clientHello, err := hex.DecodeString(bm.hello) 74 | if err != nil { 75 | b.Fatalf("failed to decode client hello hex: %v", err) 76 | } 77 | for i := 0; i < b.N; i++ { 78 | parseSNI(clientHello) 79 | } 80 | }) 81 | } 82 | } 83 | --------------------------------------------------------------------------------