├── coverage.txt ├── nws.png ├── .gitignore ├── cmd └── nws │ ├── .env │ └── nws.go ├── .github ├── codecov.yml └── workflows │ ├── test.yml │ ├── main.yaml │ └── tag.yml ├── Dockerfile ├── socks5 ├── resolver_test.go ├── credentials_test.go ├── credentials.go ├── ruleset_test.go ├── resolver.go ├── LICENSE ├── ruleset.go ├── README.md ├── tcp.go ├── auth_test.go ├── auth.go ├── socks5.go └── request.go ├── netstr ├── address.go ├── dns.go ├── dial.go ├── conn_test.go └── conn.go ├── protocol ├── nip44.go ├── message.go ├── domain_test.go ├── signer.go └── domain.go ├── exit ├── mutex.go ├── nostr.go ├── https.go └── exit.go ├── proxy └── proxy.go ├── LICENSE ├── go.mod ├── docker-compose.yaml ├── config └── config.go ├── README.md ├── strfry └── strfry.conf └── go.sum /coverage.txt: -------------------------------------------------------------------------------- 1 | mode: atomic 2 | -------------------------------------------------------------------------------- /nws.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/asmogo/nws/HEAD/nws.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | localhost.crt 2 | localhost.key 3 | .idea 4 | .vscode 5 | nostr -------------------------------------------------------------------------------- /cmd/nws/.env: -------------------------------------------------------------------------------- 1 | NOSTR_RELAYS = 'ws://0.0.0.0:7777'#NOSTR_RELAYS = 'ws://localhost:6666' 2 | NOSTR_PRIVATE_KEY = "" 3 | BACKEND_HOST = 'localhost:3338' 4 | PUBLIC = true 5 | PUBLIC_ADDRESS = 'localhost:4443' -------------------------------------------------------------------------------- /.github/codecov.yml: -------------------------------------------------------------------------------- 1 | coverage: 2 | status: 3 | patch: off 4 | project: 5 | default: 6 | target: auto 7 | # adjust accordingly based on how flaky your tests are 8 | # this allows a 10% drop from the previous base commit coverage 9 | threshold: 10% -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.21-alpine as builder 2 | 3 | ADD . /build/ 4 | 5 | WORKDIR /build 6 | RUN apk add --no-cache git bash openssh-client && \ 7 | go build -o nws cmd/nws/*.go 8 | 9 | 10 | #building finished. Now extracting single bin in second stage. 11 | FROM alpine 12 | 13 | COPY --from=builder /build/nws /app/ 14 | 15 | WORKDIR /app 16 | 17 | CMD ["./nws"] -------------------------------------------------------------------------------- /socks5/resolver_test.go: -------------------------------------------------------------------------------- 1 | package socks5 2 | 3 | import ( 4 | "testing" 5 | 6 | "context" 7 | ) 8 | 9 | func TestDNSResolver(t *testing.T) { 10 | d := DNSResolver{} 11 | ctx := context.Background() 12 | 13 | _, addr, err := d.Resolve(ctx, "localhost") 14 | if err != nil { 15 | t.Fatalf("err: %v", err) 16 | } 17 | 18 | if !addr.IsLoopback() { 19 | t.Fatalf("expected loopback") 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /netstr/address.go: -------------------------------------------------------------------------------- 1 | package netstr 2 | 3 | // NostrAddress represents a type that holds the profile and public key of a Nostr address. 4 | type NostrAddress struct { 5 | Nprofile string 6 | pubkey string 7 | } 8 | 9 | func (n NostrAddress) String() string { 10 | return n.Nprofile 11 | } 12 | 13 | // Network returns the network type of the NostrAddress, which is "nostr". 14 | func (n NostrAddress) Network() string { 15 | return "nostr" 16 | } 17 | -------------------------------------------------------------------------------- /socks5/credentials_test.go: -------------------------------------------------------------------------------- 1 | package socks5 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestStaticCredentials(t *testing.T) { 8 | creds := StaticCredentials{ 9 | "foo": "bar", 10 | "baz": "", 11 | } 12 | 13 | if !creds.Valid("foo", "bar") { 14 | t.Fatalf("expect valid") 15 | } 16 | 17 | if !creds.Valid("baz", "") { 18 | t.Fatalf("expect valid") 19 | } 20 | 21 | if creds.Valid("foo", "") { 22 | t.Fatalf("expect invalid") 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /socks5/credentials.go: -------------------------------------------------------------------------------- 1 | package socks5 2 | 3 | // CredentialStore is used to support user/pass authentication 4 | type CredentialStore interface { 5 | Valid(user, password string) bool 6 | } 7 | 8 | // StaticCredentials enables using a map directly as a credential store 9 | type StaticCredentials map[string]string 10 | 11 | func (s StaticCredentials) Valid(user, password string) bool { 12 | pass, ok := s[user] 13 | if !ok { 14 | return false 15 | } 16 | return password == pass 17 | } 18 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: tests 2 | 3 | on: [ pull_request ] 4 | 5 | jobs: 6 | golang: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v3 10 | - name: Set up Go 11 | uses: actions/setup-go@v3 12 | with: 13 | go-version: 1.21 14 | - name: Golang run tests 15 | run: go test -coverprofile=coverage.txt -covermode=atomic -v ./... 16 | - uses: codecov/codecov-action@v3 17 | with: 18 | verbose: true # optional (default = false) -------------------------------------------------------------------------------- /socks5/ruleset_test.go: -------------------------------------------------------------------------------- 1 | package socks5 2 | 3 | import ( 4 | "testing" 5 | 6 | "context" 7 | ) 8 | 9 | func TestPermitCommand(t *testing.T) { 10 | ctx := context.Background() 11 | r := &PermitCommand{true, false, false} 12 | 13 | if _, ok := r.Allow(ctx, &Request{Command: ConnectCommand}); !ok { 14 | t.Fatalf("expect connect") 15 | } 16 | 17 | if _, ok := r.Allow(ctx, &Request{Command: BindCommand}); ok { 18 | t.Fatalf("do not expect bind") 19 | } 20 | 21 | if _, ok := r.Allow(ctx, &Request{Command: AssociateCommand}); ok { 22 | t.Fatalf("do not expect associate") 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /protocol/nip44.go: -------------------------------------------------------------------------------- 1 | package protocol 2 | 3 | import ( 4 | "encoding/hex" 5 | "fmt" 6 | ) 7 | 8 | const ( 9 | padding = "02" 10 | ) 11 | 12 | func GetEncryptionKeys(privateKey, publicKey string) ([]byte, []byte, error) { 13 | targetPublicKeyBytes, err := hex.DecodeString(padding + publicKey) 14 | if err != nil { 15 | return nil, nil, fmt.Errorf("failed to decode public key: %w", err) 16 | } 17 | privateKeyBytes, err := hex.DecodeString(privateKey) 18 | if err != nil { 19 | return nil, nil, fmt.Errorf("failed to decode private key: %w", err) 20 | } 21 | return privateKeyBytes, targetPublicKeyBytes, nil 22 | } 23 | -------------------------------------------------------------------------------- /socks5/resolver.go: -------------------------------------------------------------------------------- 1 | package socks5 2 | 3 | import ( 4 | "net" 5 | 6 | "context" 7 | ) 8 | 9 | // NameResolver is used to implement custom name resolution 10 | type NameResolver interface { 11 | Resolve(ctx context.Context, name string) (context.Context, net.IP, error) 12 | } 13 | 14 | // DNSResolver uses the system DNS to resolve host names 15 | type DNSResolver struct{} 16 | 17 | func (d DNSResolver) Resolve(ctx context.Context, name string) (context.Context, net.IP, error) { 18 | addr, err := net.ResolveIPAddr("ip", name) 19 | if err != nil { 20 | return ctx, nil, err 21 | } 22 | return ctx, addr.IP, err 23 | } 24 | -------------------------------------------------------------------------------- /exit/mutex.go: -------------------------------------------------------------------------------- 1 | package exit 2 | 3 | import ( 4 | "log/slog" 5 | "sync" 6 | ) 7 | 8 | type MutexMap struct { 9 | mu sync.Mutex // a separate mutex to protect the map 10 | m map[string]*sync.Mutex // map from IDs to mutexes 11 | } 12 | 13 | func NewMutexMap() *MutexMap { 14 | return &MutexMap{ 15 | m: make(map[string]*sync.Mutex), 16 | } 17 | } 18 | 19 | func (mm *MutexMap) Lock(id string) { 20 | mm.mu.Lock() 21 | mutex, ok := mm.m[id] 22 | if !ok { 23 | mutex = &sync.Mutex{} 24 | mm.m[id] = mutex 25 | } 26 | mm.mu.Unlock() 27 | 28 | mutex.Lock() 29 | } 30 | 31 | func (mm *MutexMap) Unlock(id string) { 32 | mm.mu.Lock() 33 | mutex, ok := mm.m[id] 34 | mm.mu.Unlock() 35 | if !ok { 36 | slog.Error("mutex not found", "id", id) 37 | return 38 | } 39 | mutex.Unlock() 40 | } 41 | -------------------------------------------------------------------------------- /.github/workflows/main.yaml: -------------------------------------------------------------------------------- 1 | name: latest 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | jobs: 8 | docker: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Set up QEMU 12 | uses: docker/setup-qemu-action@v2 13 | - name: Set up Docker Buildx 14 | uses: docker/setup-buildx-action@v2 15 | - name: Login to DockerHub 16 | uses: docker/login-action@v2 17 | with: 18 | username: ${{ secrets.DOCKERHUB_USERNAME }} 19 | password: ${{ secrets.DOCKERHUB_TOKEN }} 20 | - name: Set up QEMU 21 | uses: docker/setup-qemu-action@v2 22 | - name: Docker Setup Buildx 23 | uses: docker/setup-buildx-action@v2.0.0 24 | - name: Build and push 25 | uses: docker/build-push-action@v3 26 | with: 27 | platforms: linux/amd64,linux/arm64 28 | push: true 29 | tags: asmogo/nws:latest -------------------------------------------------------------------------------- /.github/workflows/tag.yml: -------------------------------------------------------------------------------- 1 | name: gobuild 2 | 3 | 4 | on: 5 | push: 6 | # run only against tags 7 | tags: 8 | - '*' 9 | 10 | 11 | jobs: 12 | docker: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Set up QEMU 16 | uses: docker/setup-qemu-action@v2 17 | - name: Set up Docker Buildx 18 | uses: docker/setup-buildx-action@v2 19 | - name: Login to DockerHub 20 | uses: docker/login-action@v2 21 | with: 22 | username: ${{ secrets.DOCKERHUB_USERNAME }} 23 | password: ${{ secrets.DOCKERHUB_TOKEN }} 24 | - name: Set up QEMU 25 | uses: docker/setup-qemu-action@v2 26 | - name: Docker Setup Buildx 27 | uses: docker/setup-buildx-action@v2.0.0 28 | - name: Build and push 29 | uses: docker/build-push-action@v3 30 | with: 31 | platforms: linux/amd64,linux/arm64 32 | push: true 33 | tags: asmogo/nws:${{github.ref_name}} -------------------------------------------------------------------------------- /proxy/proxy.go: -------------------------------------------------------------------------------- 1 | package proxy 2 | 3 | import ( 4 | "context" 5 | "net" 6 | 7 | "github.com/asmogo/nws/config" 8 | "github.com/asmogo/nws/netstr" 9 | "github.com/asmogo/nws/socks5" 10 | "github.com/nbd-wtf/go-nostr" 11 | ) 12 | 13 | type Proxy struct { 14 | config *config.EntryConfig // the configuration for the gateway 15 | // a list of nostr relays to publish events to 16 | pool *nostr.SimplePool 17 | socksServer *socks5.Server 18 | } 19 | 20 | func New(ctx context.Context, config *config.EntryConfig) *Proxy { 21 | proxy := &Proxy{ 22 | config: config, 23 | pool: nostr.NewSimplePool(ctx), 24 | } 25 | socksServer, err := socks5.New(&socks5.Config{ 26 | Resolver: netstr.NewNostrDNS(proxy.pool, config.NostrRelays), 27 | BindIP: net.IP{0, 0, 0, 0}, 28 | }, proxy.pool, config) 29 | if err != nil { 30 | panic(err) 31 | } 32 | proxy.socksServer = socksServer 33 | return proxy 34 | } 35 | 36 | // Start should start the server 37 | func (s *Proxy) Start() error { 38 | err := s.socksServer.ListenAndServe("tcp", "8882") 39 | if err != nil { 40 | panic(err) 41 | } 42 | return nil 43 | } 44 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 asmogo 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 | -------------------------------------------------------------------------------- /socks5/LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Armon Dadgar 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | 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, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /socks5/ruleset.go: -------------------------------------------------------------------------------- 1 | package socks5 2 | 3 | import ( 4 | "context" 5 | ) 6 | 7 | // RuleSet is used to provide custom rules to allow or prohibit actions 8 | type RuleSet interface { 9 | Allow(ctx context.Context, req *Request) (context.Context, bool) 10 | } 11 | 12 | // PermitAll returns a RuleSet which allows all types of connections 13 | func PermitAll() RuleSet { 14 | return &PermitCommand{true, true, true} 15 | } 16 | 17 | // PermitNone returns a RuleSet which disallows all types of connections 18 | func PermitNone() RuleSet { 19 | return &PermitCommand{false, false, false} 20 | } 21 | 22 | // PermitCommand is an implementation of the RuleSet which 23 | // enables filtering supported commands 24 | type PermitCommand struct { 25 | EnableConnect bool 26 | EnableBind bool 27 | EnableAssociate bool 28 | } 29 | 30 | func (p *PermitCommand) Allow(ctx context.Context, req *Request) (context.Context, bool) { 31 | switch req.Command { 32 | case ConnectCommand: 33 | return ctx, p.EnableConnect 34 | case BindCommand: 35 | return ctx, p.EnableBind 36 | case AssociateCommand: 37 | return ctx, p.EnableAssociate 38 | } 39 | 40 | return ctx, false 41 | } 42 | -------------------------------------------------------------------------------- /socks5/README.md: -------------------------------------------------------------------------------- 1 | go-socks5 [![Build Status](https://travis-ci.org/armon/go-socks5.png)](https://travis-ci.org/armon/go-socks5) 2 | ========= 3 | 4 | Provides the `socks5` package that implements a [SOCKS5 server](http://en.wikipedia.org/wiki/SOCKS). 5 | SOCKS (Secure Sockets) is used to route traffic between a client and server through 6 | an intermediate proxy layer. This can be used to bypass firewalls or NATs. 7 | 8 | Feature 9 | ======= 10 | 11 | The package has the following features: 12 | * "No Auth" mode 13 | * User/Password authentication 14 | * Support for the CONNECT command 15 | * Rules to do granular filtering of commands 16 | * Custom DNS resolution 17 | * Unit tests 18 | 19 | TODO 20 | ==== 21 | 22 | The package still needs the following: 23 | * Support for the BIND command 24 | * Support for the ASSOCIATE command 25 | 26 | 27 | Example 28 | ======= 29 | 30 | Below is a simple example of usage 31 | 32 | ```go 33 | // Create a SOCKS5 server 34 | conf := &socks5.Config{} 35 | server, err := socks5.New(conf) 36 | if err != nil { 37 | panic(err) 38 | } 39 | 40 | // Create SOCKS5 proxy on localhost port 8000 41 | if err := server.ListenAndServe("tcp", "127.0.0.1:8000"); err != nil { 42 | panic(err) 43 | } 44 | ``` 45 | 46 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/asmogo/nws 2 | 3 | go 1.21.0 4 | 5 | require ( 6 | github.com/btcsuite/btcd/btcec/v2 v2.3.2 7 | github.com/caarlos0/env/v11 v11.0.0 8 | github.com/ekzyis/nip44 v0.0.0-20240425094820-6a3d864c8f08 9 | github.com/google/uuid v1.6.0 10 | github.com/joho/godotenv v1.5.1 11 | github.com/nbd-wtf/go-nostr v0.30.2 12 | github.com/puzpuzpuz/xsync/v3 v3.0.2 13 | github.com/samber/lo v1.45.0 14 | github.com/spf13/cobra v1.8.1 15 | github.com/stretchr/testify v1.9.0 16 | golang.org/x/net v0.27.0 17 | ) 18 | 19 | require ( 20 | github.com/btcsuite/btcd/btcutil v1.1.3 // indirect 21 | github.com/btcsuite/btcd/chaincfg/chainhash v1.0.2 // indirect 22 | github.com/davecgh/go-spew v1.1.1 // indirect 23 | github.com/decred/dcrd/crypto/blake256 v1.0.1 // indirect 24 | github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 // indirect 25 | github.com/gobwas/httphead v0.1.0 // indirect 26 | github.com/gobwas/pool v0.2.1 // indirect 27 | github.com/gobwas/ws v1.2.0 // indirect 28 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 29 | github.com/josharian/intern v1.0.0 // indirect 30 | github.com/kr/pretty v0.1.0 // indirect 31 | github.com/kr/text v0.2.0 // indirect 32 | github.com/mailru/easyjson v0.7.7 // indirect 33 | github.com/pmezard/go-difflib v1.0.0 // indirect 34 | github.com/spf13/pflag v1.0.5 // indirect 35 | github.com/tidwall/gjson v1.14.4 // indirect 36 | github.com/tidwall/match v1.1.1 // indirect 37 | github.com/tidwall/pretty v1.2.0 // indirect 38 | golang.org/x/crypto v0.25.0 // indirect 39 | golang.org/x/exp v0.0.0-20230425010034-47ecfdc1ba53 // indirect 40 | golang.org/x/sys v0.22.0 // indirect 41 | golang.org/x/text v0.16.0 // indirect 42 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect 43 | gopkg.in/yaml.v3 v3.0.1 // indirect 44 | ) 45 | -------------------------------------------------------------------------------- /netstr/dns.go: -------------------------------------------------------------------------------- 1 | package netstr 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "net" 8 | "strings" 9 | "time" 10 | 11 | "github.com/asmogo/nws/protocol" 12 | "github.com/nbd-wtf/go-nostr" 13 | ) 14 | 15 | // NostrDNS does not resolve anything. 16 | type NostrDNS struct { 17 | pool *nostr.SimplePool 18 | nostrRelays []string 19 | } 20 | 21 | var ( 22 | errPoolIsNil = errors.New("pool is nil") 23 | errFailedToFindExitNodeEvent = errors.New("failed to find exit node event") 24 | errExitNodeEventIsExpired = errors.New("exit node event is expired") 25 | ) 26 | 27 | func NewNostrDNS(pool *nostr.SimplePool, nostrRelays []string) *NostrDNS { 28 | return &NostrDNS{ 29 | pool: pool, 30 | nostrRelays: nostrRelays, 31 | } 32 | } 33 | 34 | func (d NostrDNS) Resolve(ctx context.Context, name string) (context.Context, net.IP, error) { 35 | if strings.HasSuffix(name, ".nostr") || strings.HasPrefix(name, "npub") || strings.HasPrefix(name, "nprofile") { 36 | 37 | return ctx, nil, nil 38 | } 39 | addr, err := net.ResolveIPAddr("ip", name) 40 | if err != nil { 41 | return ctx, nil, fmt.Errorf("failed to resolve ip address: %w", err) 42 | } 43 | if d.pool == nil { 44 | return ctx, nil, errPoolIsNil 45 | } 46 | since := nostr.Timestamp(time.Now().Add(-time.Second * 10).Unix()) 47 | ev := d.pool.QuerySingle(ctx, d.nostrRelays, nostr.Filter{ 48 | Kinds: []int{protocol.KindAnnouncementEvent}, 49 | Since: &since, 50 | }) 51 | if ev == nil { 52 | return ctx, nil, errFailedToFindExitNodeEvent 53 | } 54 | if ev.CreatedAt < since { 55 | return ctx, nil, errExitNodeEventIsExpired 56 | } 57 | ctx = context.WithValue(ctx, TargetPublicKey, ev.PubKey) 58 | return ctx, addr.IP, nil 59 | } 60 | 61 | type ContextKeyTargetPublicKey string 62 | 63 | const TargetPublicKey ContextKeyTargetPublicKey = "TargetPublicKey" 64 | -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | networks: 4 | nostr: 5 | enable_ipv6: true 6 | ipam: 7 | config: 8 | - subnet: fd00:db8:a::/64 9 | gateway: fd00:db8:a::1 10 | services: 11 | mint: 12 | image: cashubtc/nutshell:0.15.3 13 | container_name: mint 14 | ports: 15 | - "3338" 16 | networks: 17 | nostr: 18 | environment: 19 | - MINT_BACKEND_BOLT11_SAT=FakeWallet 20 | - MINT_LISTEN_HOST=0.0.0.0 21 | - MINT_LISTEN_PORT=3338 22 | - MINT_PRIVATE_KEY=TEST_PRIVATE_KEY 23 | - MINT_INFO_DESCRIPTION=This Cashu test mint has no public IP address and can only be reached via NWS powered by Nostr 24 | - MINT_INFO_NAME=Cashu NWS mint 25 | command: ["poetry", "run", "mint"] 26 | exit: 27 | build: 28 | context: . 29 | container_name: exit 30 | command: [ "./nws","exit" ] 31 | networks: 32 | nostr: 33 | environment: 34 | - NOSTR_RELAYS=ws://nostr-relay:7777 35 | - NOSTR_PRIVATE_KEY= 36 | - BACKEND_HOST=mint:3338 37 | depends_on: 38 | - mint 39 | - nostr 40 | exit-https: 41 | build: 42 | context: . 43 | container_name: exit-https 44 | command: ["./nws","exit","--port", "4443", "--target", "http://mint:3338"] 45 | networks: 46 | nostr: 47 | environment: 48 | - NOSTR_RELAYS=ws://nostr-relay:7777 49 | - NOSTR_PRIVATE_KEY= 50 | - BACKEND_HOST=:4443 51 | depends_on: 52 | - mint 53 | - nostr 54 | entry: 55 | build: 56 | context: . 57 | command: [ "./nws","entry"] 58 | container_name: entry 59 | ports: 60 | - 8882:8882 61 | networks: 62 | nostr: 63 | environment: 64 | - NOSTR_RELAYS=ws://nostr-relay:7777 65 | depends_on: 66 | - nostr 67 | nostr: 68 | image: carroarmato0/strfry:latest 69 | container_name: nostr-relay 70 | ports: 71 | - 7777:7777 72 | networks: 73 | nostr: 74 | restart: always 75 | volumes: 76 | - ./strfry/data:/app/strfry-db/:Z 77 | - ./strfry/strfry.conf:/app/strfry.conf:ro,Z 78 | -------------------------------------------------------------------------------- /exit/nostr.go: -------------------------------------------------------------------------------- 1 | package exit 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "log/slog" 8 | "strconv" 9 | "time" 10 | 11 | "github.com/asmogo/nws/protocol" 12 | "github.com/nbd-wtf/go-nostr" 13 | ) 14 | 15 | const ten = 10 16 | 17 | var errNoPublicKey = errors.New("no public configuration") 18 | 19 | func (e *Exit) announceExitNode(ctx context.Context) error { 20 | if !e.config.Public { 21 | return errNoPublicKey 22 | } 23 | go func() { 24 | for { 25 | event := nostr.Event{ 26 | PubKey: e.publicKey, 27 | CreatedAt: nostr.Now(), 28 | Kind: protocol.KindAnnouncementEvent, 29 | Tags: nostr.Tags{ 30 | nostr.Tag{"expiration", strconv.FormatInt(time.Now().Add(time.Second*ten).Unix(), ten)}, 31 | }, 32 | } 33 | err := event.Sign(e.config.NostrPrivateKey) 34 | if err != nil { 35 | slog.Error("could not sign event", "error", err) 36 | continue 37 | } 38 | // publish the event 39 | for _, relay := range e.relays { 40 | err = relay.Publish(ctx, event) 41 | if err != nil { 42 | slog.Error("could not publish event", "error", err) 43 | // do not return here, try to publish the event to other relays 44 | } 45 | } 46 | time.Sleep(time.Second * ten) 47 | } 48 | }() 49 | return nil 50 | } 51 | 52 | func (e *Exit) DeleteEvent(ctx context.Context, event *nostr.Event) error { 53 | for _, responseRelay := range e.config.NostrRelays { 54 | var relay *nostr.Relay 55 | relay, err := e.pool.EnsureRelay(responseRelay) 56 | if err != nil { 57 | return fmt.Errorf("failed to ensure relay: %w", err) 58 | } 59 | event := nostr.Event{ 60 | CreatedAt: nostr.Now(), 61 | PubKey: e.publicKey, 62 | Kind: nostr.KindDeletion, 63 | Tags: nostr.Tags{ 64 | nostr.Tag{"e", event.ID}, 65 | }, 66 | } 67 | err = event.Sign(e.config.NostrPrivateKey) 68 | if err != nil { 69 | return fmt.Errorf("failed to sign event: %w", err) 70 | } 71 | err = relay.Publish(ctx, event) 72 | if err != nil { 73 | return fmt.Errorf("failed to publish event: %w", err) 74 | } 75 | } 76 | return nil 77 | } 78 | -------------------------------------------------------------------------------- /socks5/tcp.go: -------------------------------------------------------------------------------- 1 | package socks5 2 | 3 | import ( 4 | "fmt" 5 | "log/slog" 6 | "net" 7 | 8 | "github.com/google/uuid" 9 | "github.com/puzpuzpuz/xsync/v3" 10 | ) 11 | 12 | type TCPListener struct { 13 | listener net.Listener 14 | connectChannels *xsync.MapOf[string, chan net.Conn] // todo -- use [16]byte for uuid instead of string 15 | } 16 | 17 | const uuidLength = 36 18 | 19 | func NewTCPListener(address string) (*TCPListener, error) { 20 | l, err := net.Listen("tcp", address) 21 | if err != nil { 22 | return nil, fmt.Errorf("failed to create tcp listener: %w", err) 23 | } 24 | return &TCPListener{ 25 | listener: l, 26 | connectChannels: xsync.NewMapOf[string, chan net.Conn](), 27 | }, nil 28 | } 29 | 30 | func (l *TCPListener) AddConnectChannel(uuid uuid.UUID, ch chan net.Conn) { 31 | l.connectChannels.Store(uuid.String(), ch) 32 | } 33 | 34 | // Start starts the listener 35 | func (l *TCPListener) Start() { 36 | for { 37 | conn, err := l.listener.Accept() 38 | if err != nil { 39 | return 40 | } 41 | go l.handleConnection(conn) 42 | } 43 | } 44 | 45 | // handleConnection handles the connection 46 | // It reads the uuid from the connection, checks if the uuid is in the map, and sends the connection to the channel 47 | // It does not close the connection 48 | func (l *TCPListener) handleConnection(conn net.Conn) { 49 | response := []byte{1} 50 | for { 51 | // read uuid from the connection 52 | readbuffer := make([]byte, uuidLength) 53 | _, err := conn.Read(readbuffer) 54 | if err != nil { 55 | return 56 | } 57 | // check if uuid is in the map 58 | connectionID := string(readbuffer) 59 | connChannel, ok := l.connectChannels.Load(connectionID) 60 | if !ok { 61 | slog.Error("uuid not found in map") 62 | continue 63 | } 64 | slog.Info("uuid found in map") 65 | l.connectChannels.Delete(connectionID) 66 | _, err = conn.Write(response) 67 | if err != nil { 68 | close(connChannel) // close the channel 69 | slog.Error("failed to write response to connection", "err", err) 70 | return 71 | } 72 | // send the connection to the channel 73 | connChannel <- conn 74 | return 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /protocol/message.go: -------------------------------------------------------------------------------- 1 | package protocol 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | 7 | "github.com/google/uuid" 8 | ) 9 | 10 | type MessageType string 11 | 12 | const ( 13 | MessageTypeSocks5 = MessageType("SOCKS5RESPONSE") 14 | MessageConnect = MessageType("CONNECT") 15 | MessageConnectReverse = MessageType("CONNECTR") 16 | ) 17 | 18 | type Message struct { 19 | Key uuid.UUID `json:"key,omitempty"` // unique identifier for the message 20 | Type MessageType `json:"type,omitempty"` // type of message 21 | Data []byte `json:"data,omitempty"` // data to be sent 22 | Destination string `json:"destination,omitempty"` // destination to send the message 23 | EntryPublicAddress string `json:"entryPublicAddress,omitempty"` // public ip address of the entry node (used for reverse connect) 24 | } 25 | 26 | type MessageOption func(*Message) 27 | 28 | func WithUUID(uuid uuid.UUID) MessageOption { 29 | return func(m *Message) { 30 | m.Key = uuid 31 | } 32 | } 33 | 34 | func WithType(messageType MessageType) MessageOption { 35 | return func(m *Message) { 36 | m.Type = messageType 37 | } 38 | } 39 | 40 | func WithDestination(destination string) MessageOption { 41 | return func(m *Message) { 42 | m.Destination = destination 43 | } 44 | } 45 | 46 | func WithEntryPublicAddress(entryPublicAddress string) MessageOption { 47 | return func(m *Message) { 48 | m.EntryPublicAddress = entryPublicAddress 49 | } 50 | } 51 | func WithData(data []byte) MessageOption { 52 | return func(m *Message) { 53 | m.Data = data 54 | } 55 | } 56 | 57 | func NewMessage(configs ...MessageOption) *Message { 58 | m := &Message{} 59 | for _, config := range configs { 60 | config(m) 61 | } 62 | return m 63 | } 64 | func MarshalJSON(m *Message) ([]byte, error) { 65 | data, err := json.Marshal(m) 66 | if err != nil { 67 | return nil, fmt.Errorf("could not marshal message: %w", err) 68 | } 69 | return data, nil 70 | } 71 | 72 | func UnmarshalJSON(data []byte) (*Message, error) { 73 | m := NewMessage() 74 | if err := json.Unmarshal(data, &m); err != nil { 75 | return nil, fmt.Errorf("could not unmarshal message: %w", err) 76 | } 77 | return m, nil 78 | } 79 | -------------------------------------------------------------------------------- /cmd/nws/nws.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log/slog" 6 | 7 | "github.com/asmogo/nws/config" 8 | "github.com/asmogo/nws/exit" 9 | "github.com/asmogo/nws/proxy" 10 | "github.com/spf13/cobra" 11 | ) 12 | 13 | const ( 14 | usagePort = "set the https reverse proxy port" 15 | usageTarget = "set https reverse proxy target (your local service)" 16 | ) 17 | 18 | func main() { 19 | rootCmd := &cobra.Command{Use: "nws"} 20 | exitCmd := &cobra.Command{Use: "exit", Run: startExitNode} 21 | var httpsPort int32 22 | var httpTarget string 23 | exitCmd.Flags().Int32VarP(&httpsPort, "port", "p", 0, usagePort) 24 | exitCmd.Flags().StringVarP(&httpTarget, "target", "t", "", usageTarget) 25 | entryCmd := &cobra.Command{Use: "entry", Run: startEntryNode} 26 | rootCmd.AddCommand(exitCmd) 27 | rootCmd.AddCommand(entryCmd) 28 | err := rootCmd.Execute() 29 | if err != nil { 30 | panic(err) 31 | } 32 | } 33 | 34 | // updateConfigFlag updates the configuration with the provided flags. 35 | func updateConfigFlag(cmd *cobra.Command, cfg *config.ExitConfig) error { 36 | httpsPort, err := cmd.Flags().GetInt32("port") 37 | if err != nil { 38 | return fmt.Errorf("failed to get https port: %w", err) 39 | } 40 | httpTarget, err := cmd.Flags().GetString("target") 41 | if err != nil { 42 | return fmt.Errorf("failed to get http target: %w", err) 43 | } 44 | cfg.HttpsPort = httpsPort 45 | cfg.HttpsTarget = httpTarget 46 | return nil 47 | } 48 | 49 | func startExitNode(cmd *cobra.Command, _ []string) { 50 | slog.Info("Starting exit node") 51 | // load the configuration 52 | cfg, err := config.LoadConfig[config.ExitConfig]() 53 | if err != nil { 54 | panic(err) 55 | } 56 | if len(cfg.NostrRelays) == 0 { 57 | slog.Info("No relays provided, using default relays") 58 | cfg.NostrRelays = config.DefaultRelays 59 | } 60 | err = updateConfigFlag(cmd, cfg) 61 | if err != nil { 62 | panic(err) 63 | } 64 | ctx := cmd.Context() 65 | exitNode := exit.New(ctx, cfg) 66 | exitNode.ListenAndServe(ctx) 67 | } 68 | 69 | func startEntryNode(cmd *cobra.Command, _ []string) { 70 | slog.Info("Starting entry node") 71 | cfg, err := config.LoadConfig[config.EntryConfig]() 72 | if err != nil { 73 | panic(err) 74 | } 75 | // create a new gw server 76 | socksProxy := proxy.New(cmd.Context(), cfg) 77 | err = socksProxy.Start() 78 | if err != nil { 79 | panic(err) 80 | } 81 | 82 | } 83 | -------------------------------------------------------------------------------- /config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | "log/slog" 6 | "os" 7 | 8 | "github.com/caarlos0/env/v11" 9 | "github.com/joho/godotenv" 10 | ) 11 | 12 | type EntryConfig struct { 13 | NostrRelays []string `env:"NOSTR_RELAYS" envSeparator:";"` 14 | PublicAddress string `env:"PUBLIC_ADDRESS"` 15 | } 16 | 17 | type ExitConfig struct { 18 | NostrRelays []string `env:"NOSTR_RELAYS" envSeparator:";"` 19 | NostrPrivateKey string `env:"NOSTR_PRIVATE_KEY"` 20 | BackendHost string `env:"BACKEND_HOST"` 21 | BackendScheme string `env:"BACKEND_SCHEME"` 22 | HttpsPort int32 23 | HttpsTarget string 24 | Public bool `env:"PUBLIC"` 25 | } 26 | 27 | var DefaultRelays = []string{ 28 | "wss://relay.8333.space", 29 | } 30 | 31 | // load the and marshal Configuration from .env file from the UserHomeDir 32 | // if this file was not found, fallback to the os environment variables 33 | func LoadConfig[T any]() (*T, error) { 34 | // load current users home directory as a string 35 | homeDir, err := os.UserHomeDir() 36 | if err != nil { 37 | slog.Error("error loading home directory", err) 38 | } 39 | // check if .env file exist in the home directory 40 | // if it does, load the configuration from it 41 | // else fallback to the os environment variables 42 | if _, err := os.Stat(homeDir + "/.env"); err == nil { 43 | // load configuration from .env file 44 | return loadFromEnv[T](homeDir + "/.env") 45 | } else if _, err := os.Stat(".env"); err == nil { 46 | // load configuration from .env file in current directory 47 | return loadFromEnv[T]("") 48 | } else { 49 | // load configuration from os environment variables 50 | return loadFromEnv[T]("") 51 | } 52 | } 53 | 54 | // loadFromEnv loads the configuration from the specified .env file path. 55 | // If the path is empty, it does not load any configuration. 56 | // It returns an error if there was a problem loading the configuration. 57 | func loadFromEnv[T any](path string) (*T, error) { 58 | // check path 59 | 60 | // load configuration from .env file 61 | err := godotenv.Load() 62 | if err != nil { 63 | cfg, err := env.ParseAs[T]() 64 | if err != nil { 65 | fmt.Printf("%+v\n", err) 66 | } 67 | return &cfg, nil 68 | } 69 | 70 | // or you can use generics 71 | cfg, err := env.ParseAs[T]() 72 | if err != nil { 73 | fmt.Printf("%+v\n", err) 74 | } 75 | return &cfg, nil 76 | } 77 | -------------------------------------------------------------------------------- /protocol/domain_test.go: -------------------------------------------------------------------------------- 1 | package protocol_test 2 | 3 | import ( 4 | "net/url" 5 | "reflect" 6 | "testing" 7 | 8 | "github.com/asmogo/nws/protocol" 9 | ) 10 | 11 | type args struct { 12 | s string 13 | } 14 | 15 | type parseTest struct { 16 | name string 17 | args args 18 | want *protocol.URL 19 | wantErr bool 20 | } 21 | 22 | func TestParse(t *testing.T) { 23 | t.Parallel() 24 | for _, test := range createParseTests() { 25 | testCopy := test 26 | t.Run(testCopy.name, func(t *testing.T) { 27 | t.Parallel() 28 | got, err := protocol.Parse(testCopy.args.s) 29 | if (err != nil) != testCopy.wantErr { 30 | t.Errorf("Parse() error = %v, wantErr %v", err, testCopy.wantErr) 31 | return 32 | } 33 | if !reflect.DeepEqual(got, testCopy.want) { 34 | t.Errorf("Parse() got = %v, want %v", got, testCopy.want) 35 | } 36 | }) 37 | } 38 | } 39 | 40 | func createParseTests() []parseTest { 41 | return []parseTest{ 42 | {name: "1", args: args{s: "http://D1Q78S3J78NIURJFEDQ74BJQCLH6AP35CKN66R3FELI0.9B7NTQSU4PBM2JJQJ0CMGHUENQON4GB28RLGQCH3D3NK2AQVFE70.nostr"}, want: &protocol.URL{IsDomain: true, TLD: "nostr", Name: "9b7ntqsu4pbm2jjqj0cmghuenqon4gb28rlgqch3d3nk2aqvfe70", SubName: "d1q78s3j78niurjfedq74bjqclh6ap35ckn66r3feli0", URL: &url.URL{Host: "d1q78s3j78niurjfedq74bjqclh6ap35ckn66r3feli0.9b7ntqsu4pbm2jjqj0cmghuenqon4gb28rlgqch3d3nk2aqvfe70.nostr", Scheme: "http"}}, wantErr: false}, //nolint:lll 43 | {name: "1", args: args{s: "http://d1q78s3j78niurjfedq74bjqclh6ap35ckn66r3feli0.9b7ntqsu4pbm2jjqj0cmghuenqon4gb28rlgqch3d3nk2aqvfe70.nostr"}, want: &protocol.URL{IsDomain: true, TLD: "nostr", Name: "9b7ntqsu4pbm2jjqj0cmghuenqon4gb28rlgqch3d3nk2aqvfe70", SubName: "d1q78s3j78niurjfedq74bjqclh6ap35ckn66r3feli0", URL: &url.URL{Host: "d1q78s3j78niurjfedq74bjqclh6ap35ckn66r3feli0.9b7ntqsu4pbm2jjqj0cmghuenqon4gb28rlgqch3d3nk2aqvfe70.nostr", Scheme: "http"}}, wantErr: false}, //nolint:lll 44 | {name: "1", args: args{s: "https://d1q78s3j78niurjfedq74bjqclh6ap35ckn66r3feli0.9b7ntqsu4pbm2jjqj0cmghuenqon4gb28rlgqch3d3nk2aqvfe70.nostr"}, want: &protocol.URL{IsDomain: true, TLD: "nostr", Name: "9b7ntqsu4pbm2jjqj0cmghuenqon4gb28rlgqch3d3nk2aqvfe70", SubName: "d1q78s3j78niurjfedq74bjqclh6ap35ckn66r3feli0", URL: &url.URL{Host: "d1q78s3j78niurjfedq74bjqclh6ap35ckn66r3feli0.9b7ntqsu4pbm2jjqj0cmghuenqon4gb28rlgqch3d3nk2aqvfe70.nostr", Scheme: "https"}}, wantErr: false}, //nolint:lll 45 | 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /socks5/auth_test.go: -------------------------------------------------------------------------------- 1 | package socks5 2 | 3 | import ( 4 | "bytes" 5 | "github.com/asmogo/nws/config" 6 | "github.com/nbd-wtf/go-nostr" 7 | "testing" 8 | ) 9 | 10 | func TestNoAuth(t *testing.T) { 11 | req := bytes.NewBuffer(nil) 12 | req.Write([]byte{1, NoAuth}) 13 | var resp bytes.Buffer 14 | 15 | s, _ := New(&Config{}, &nostr.SimplePool{}, &config.EntryConfig{}) 16 | ctx, err := s.authenticate(&resp, req) 17 | if err != nil { 18 | t.Fatalf("err: %v", err) 19 | } 20 | 21 | if ctx.Method != NoAuth { 22 | t.Fatal("Invalid Context Method") 23 | } 24 | 25 | out := resp.Bytes() 26 | if !bytes.Equal(out, []byte{socks5Version, NoAuth}) { 27 | t.Fatalf("bad: %v", out) 28 | } 29 | } 30 | 31 | func TestPasswordAuth_Valid(t *testing.T) { 32 | req := bytes.NewBuffer(nil) 33 | req.Write([]byte{2, NoAuth, UserPassAuth}) 34 | req.Write([]byte{1, 3, 'f', 'o', 'o', 3, 'b', 'a', 'r'}) 35 | var resp bytes.Buffer 36 | 37 | cred := StaticCredentials{ 38 | "foo": "bar", 39 | } 40 | 41 | cator := UserPassAuthenticator{Credentials: cred} 42 | 43 | s, _ := New(&Config{AuthMethods: []Authenticator{cator}}, &nostr.SimplePool{}, &config.EntryConfig{}) 44 | 45 | ctx, err := s.authenticate(&resp, req) 46 | if err != nil { 47 | t.Fatalf("err: %v", err) 48 | } 49 | 50 | if ctx.Method != UserPassAuth { 51 | t.Fatal("Invalid Context Method") 52 | } 53 | 54 | val, ok := ctx.Payload["Username"] 55 | if !ok { 56 | t.Fatal("Missing key Username in auth context's payload") 57 | } 58 | 59 | if val != "foo" { 60 | t.Fatal("Invalid Username in auth context's payload") 61 | } 62 | 63 | out := resp.Bytes() 64 | if !bytes.Equal(out, []byte{socks5Version, UserPassAuth, 1, authSuccess}) { 65 | t.Fatalf("bad: %v", out) 66 | } 67 | } 68 | 69 | func TestPasswordAuth_Invalid(t *testing.T) { 70 | req := bytes.NewBuffer(nil) 71 | req.Write([]byte{2, NoAuth, UserPassAuth}) 72 | req.Write([]byte{1, 3, 'f', 'o', 'o', 3, 'b', 'a', 'z'}) 73 | var resp bytes.Buffer 74 | 75 | cred := StaticCredentials{ 76 | "foo": "bar", 77 | } 78 | cator := UserPassAuthenticator{Credentials: cred} 79 | s, _ := New(&Config{AuthMethods: []Authenticator{cator}}, &nostr.SimplePool{}, &config.EntryConfig{}) 80 | 81 | ctx, err := s.authenticate(&resp, req) 82 | if err != UserAuthFailed { 83 | t.Fatalf("err: %v", err) 84 | } 85 | 86 | if ctx != nil { 87 | t.Fatal("Invalid Context Method") 88 | } 89 | 90 | out := resp.Bytes() 91 | if !bytes.Equal(out, []byte{socks5Version, UserPassAuth, 1, authFailure}) { 92 | t.Fatalf("bad: %v", out) 93 | } 94 | } 95 | 96 | func TestNoSupportedAuth(t *testing.T) { 97 | req := bytes.NewBuffer(nil) 98 | req.Write([]byte{1, NoAuth}) 99 | var resp bytes.Buffer 100 | 101 | cred := StaticCredentials{ 102 | "foo": "bar", 103 | } 104 | cator := UserPassAuthenticator{Credentials: cred} 105 | 106 | s, _ := New(&Config{AuthMethods: []Authenticator{cator}}, &nostr.SimplePool{}, &config.EntryConfig{}) 107 | 108 | ctx, err := s.authenticate(&resp, req) 109 | if err != NoSupportedAuth { 110 | t.Fatalf("err: %v", err) 111 | } 112 | 113 | if ctx != nil { 114 | t.Fatal("Invalid Context Method") 115 | } 116 | 117 | out := resp.Bytes() 118 | if !bytes.Equal(out, []byte{socks5Version, noAcceptable}) { 119 | t.Fatalf("bad: %v", out) 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /protocol/signer.go: -------------------------------------------------------------------------------- 1 | package protocol 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/ekzyis/nip44" 7 | "github.com/nbd-wtf/go-nostr" 8 | ) 9 | 10 | // KindEphemeralEvent represents the unique identifier for ephemeral events. 11 | const KindEphemeralEvent int = 28333 12 | 13 | // KindAnnouncementEvent represents the unique identifier for announcement events. 14 | const KindAnnouncementEvent int = 38333 15 | 16 | // KindCertificateEvent represents the unique identifier for certificate events. 17 | const KindCertificateEvent int = 38334 18 | 19 | // KindPrivateKeyEvent represents the unique identifier for private key events. 20 | const KindPrivateKeyEvent int = 38335 21 | 22 | // EventSigner represents a signer that can create and sign events. 23 | // 24 | // EventSigner provides methods for creating unsigned events, creating signed events. 25 | type EventSigner struct { 26 | PublicKey string 27 | privateKey string 28 | } 29 | 30 | // NewEventSigner creates a new EventSigner. 31 | func NewEventSigner(privateKey string) (*EventSigner, error) { 32 | myPublicKey, err := nostr.GetPublicKey(privateKey) 33 | if err != nil { 34 | return nil, fmt.Errorf("could not generate public key: %w", err) 35 | } 36 | signer := &EventSigner{ 37 | privateKey: privateKey, 38 | PublicKey: myPublicKey, 39 | } 40 | return signer, nil 41 | } 42 | 43 | // CreateEvent creates a new Event with the provided tags. The Public Key and the 44 | // current timestamp are set automatically. The Kind is set to KindEphemeralEvent. 45 | func (s *EventSigner) CreateEvent(kind int, tags nostr.Tags) nostr.Event { 46 | return nostr.Event{ 47 | PubKey: s.PublicKey, 48 | CreatedAt: nostr.Now(), 49 | Kind: kind, 50 | Tags: tags, 51 | } 52 | } 53 | 54 | // CreateSignedEvent creates a signed Nostr event with the provided target public key, tags, and options. 55 | // It computes the shared key between the target public key and the private key of the EventSigner. 56 | // Then, it creates a new message with the provided options. 57 | // The message is serialized to JSON and encrypted using the shared key. 58 | // The method then calls CreateEvent to create a new unsigned event with the provided tags. 59 | // The encrypted message is set as the content of the event. 60 | // Finally, the event is signed with the private key of the EventSigner, setting the event ID and event Sig fields. 61 | // The signed event is returned along with any error that occurs. 62 | func (s *EventSigner) CreateSignedEvent( 63 | targetPublicKey string, 64 | kind int, 65 | tags nostr.Tags, 66 | opts ...MessageOption, 67 | ) (nostr.Event, error) { 68 | privateKeyBytes, targetPublicKeyBytes, err := GetEncryptionKeys(s.privateKey, targetPublicKey) 69 | if err != nil { 70 | return nostr.Event{}, fmt.Errorf("could not get encryption keys: %w", err) 71 | } 72 | sharedKey, err := nip44.GenerateConversationKey(privateKeyBytes, targetPublicKeyBytes) 73 | if err != nil { 74 | return nostr.Event{}, fmt.Errorf("could not compute shared key: %w", err) 75 | } 76 | message := NewMessage( 77 | opts..., 78 | ) 79 | messageJSON, err := MarshalJSON(message) 80 | if err != nil { 81 | return nostr.Event{}, fmt.Errorf("could not marshal message: %w", err) 82 | } 83 | encryptedMessage, err := nip44.Encrypt(sharedKey, string(messageJSON), &nip44.EncryptOptions{ 84 | Salt: nil, 85 | Version: 0, 86 | }) 87 | if err != nil { 88 | return nostr.Event{}, fmt.Errorf("could not encrypt message: %w", err) 89 | } 90 | event := s.CreateEvent(kind, tags) 91 | event.Content = encryptedMessage 92 | // calling Sign sets the event ID field and the event Sig field 93 | err = event.Sign(s.privateKey) 94 | if err != nil { 95 | return nostr.Event{}, fmt.Errorf("could not sign event: %w", err) 96 | } 97 | return event, nil 98 | } 99 | -------------------------------------------------------------------------------- /netstr/dial.go: -------------------------------------------------------------------------------- 1 | package netstr 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log/slog" 7 | "net" 8 | 9 | "github.com/asmogo/nws/config" 10 | "github.com/asmogo/nws/protocol" 11 | "github.com/google/uuid" 12 | "github.com/nbd-wtf/go-nostr" 13 | ) 14 | 15 | type DialOptions struct { 16 | Pool *nostr.SimplePool 17 | PublicAddress string 18 | ConnectionID uuid.UUID 19 | MessageType protocol.MessageType 20 | TargetPublicKey string 21 | } 22 | 23 | // DialSocks connects to a destination using the provided SimplePool and returns a Dialer function. 24 | // It creates a new Connection using the specified context, private key, destination address, 25 | // It parses the destination address to get the public key and relays. 26 | // It creates a signed event using the private key, public key, and destination address. 27 | // It ensures that the relays are available in the pool and publishes the signed event to each relay. 28 | // Finally, it returns the Connection and nil error. If there are any errors, nil connection and the error are returned. 29 | func DialSocks( 30 | options DialOptions, 31 | config *config.EntryConfig, 32 | ) func(ctx context.Context, _, addr string) (net.Conn, error) { 33 | return func(ctx context.Context, _, addr string) (net.Conn, error) { 34 | key := nostr.GeneratePrivateKey() 35 | connection := NewConnection(ctx, 36 | WithPrivateKey(key), 37 | WithDst(addr), 38 | WithSub(), 39 | WithDefaultRelays(config.NostrRelays), 40 | WithTargetPublicKey(options.TargetPublicKey), 41 | WithUUID(options.ConnectionID)) 42 | 43 | var publicKey string 44 | var relays []string 45 | var err error 46 | if options.TargetPublicKey != "" { 47 | publicKey, relays = options.TargetPublicKey, config.NostrRelays 48 | } else { 49 | publicKey, relays, err = connection.parseDestination() 50 | if err != nil { 51 | slog.Error("error parsing host", "error", err) 52 | return nil, fmt.Errorf("error parsing host: %w", err) 53 | } 54 | } 55 | // create nostr signed event 56 | signer, err := protocol.NewEventSigner(key) 57 | if err != nil { 58 | return nil, fmt.Errorf("error creating signer: %w", err) 59 | } 60 | opts := []protocol.MessageOption{ 61 | protocol.WithType(options.MessageType), 62 | protocol.WithUUID(options.ConnectionID), 63 | } 64 | if options.PublicAddress != "" { 65 | opts = append(opts, protocol.WithEntryPublicAddress(options.PublicAddress)) 66 | } 67 | opts = append(opts, protocol.WithDestination(addr)) 68 | 69 | err = createAndPublish(ctx, signer, publicKey, opts, relays, options) 70 | if err != nil { 71 | return nil, fmt.Errorf("error publishing event: %w", err) 72 | } 73 | return connection, nil 74 | } 75 | } 76 | 77 | // createAndPublish creates a signed event using the provided signer, public key, message options, and relays. 78 | // It then publishes the event to each relay. Returns an error if signing or publishing fails. 79 | func createAndPublish( 80 | ctx context.Context, 81 | signer *protocol.EventSigner, 82 | publicKey string, 83 | opts []protocol.MessageOption, 84 | relays []string, 85 | options DialOptions, 86 | ) error { 87 | signedEvent, err := signer.CreateSignedEvent( 88 | publicKey, 89 | protocol.KindEphemeralEvent, 90 | nostr.Tags{nostr.Tag{"p", publicKey}}, 91 | opts...) 92 | if err != nil { 93 | return fmt.Errorf("error creating signed event: %w", err) 94 | } 95 | for _, relayURL := range relays { 96 | var relay *nostr.Relay 97 | relay, err = options.Pool.EnsureRelay(relayURL) 98 | if err != nil { 99 | slog.Error("error creating relay", "error", err) 100 | continue 101 | } 102 | err = relay.Publish(ctx, signedEvent) 103 | if err != nil { 104 | return fmt.Errorf("error publishing event: %w", err) 105 | } 106 | } 107 | return nil 108 | } 109 | -------------------------------------------------------------------------------- /netstr/conn_test.go: -------------------------------------------------------------------------------- 1 | package netstr 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "github.com/asmogo/nws/protocol" 7 | "github.com/ekzyis/nip44" 8 | "github.com/nbd-wtf/go-nostr" 9 | "runtime" 10 | "testing" 11 | 12 | "github.com/google/uuid" 13 | "github.com/stretchr/testify/assert" 14 | ) 15 | 16 | func TestNostrConnection_Read(t *testing.T) { 17 | tests := []struct { 18 | name string 19 | event nostr.IncomingEvent 20 | nc func() *NostrConnection 21 | wantN int 22 | wantErr bool 23 | }{ 24 | { 25 | name: "Read invalid relay", 26 | event: nostr.IncomingEvent{Relay: nil}, 27 | nc: func() *NostrConnection { 28 | ctx, cancelFunc := context.WithCancel(context.Background()) 29 | return &NostrConnection{ 30 | uuid: uuid.New(), 31 | ctx: ctx, 32 | cancel: cancelFunc, 33 | subscriptionChan: make(chan nostr.IncomingEvent, 1), 34 | privateKey: "788de536151854213cc28dff9c3042e7897f0a1d59b391ddbbc1619d7e716e78", 35 | } 36 | }, 37 | wantN: 0, 38 | wantErr: false, 39 | }, 40 | { 41 | name: "Read", 42 | event: nostr.IncomingEvent{ 43 | Relay: &nostr.Relay{URL: "wss://relay.example.com"}, 44 | Event: &nostr.Event{ 45 | ID: "eventID", 46 | PubKey: "8f97a664471f0b6d599a1e4a781c9a25f39902d96fb462c08df48697bb851611", 47 | Content: `AuaBj8mXZ9n9IfdonNra0lpaed6Alc+H0xjUdyN9h6mCSuy7ZrEjWUZQj4HWNd4P1RCme1pda0z8hyItT4nVzESByRiQT5+hf+ij0aJw9+DW/ggJIWGbpm4wp7bk4loYKdERr+nzorqEjWNzpxsJXhXJ0nKtIxu61To5XY4SjuMqpUuOtznuHiPJJhKNWSSRPV92L/iVoOnjKJhfR5jOWBK3vA==`}}, 48 | nc: func() *NostrConnection { 49 | ctx, cancelFunc := context.WithCancel(context.Background()) 50 | return &NostrConnection{ 51 | uuid: uuid.New(), 52 | ctx: ctx, 53 | cancel: cancelFunc, 54 | subscriptionChan: make(chan nostr.IncomingEvent, 1), 55 | privateKey: "788de536151854213cc28dff9c3042e7897f0a1d59b391ddbbc1619d7e716e78", 56 | } 57 | }, 58 | wantN: 5, // hello world 59 | wantErr: false, 60 | }, 61 | // Add more cases here to cover more corner situations 62 | } 63 | for _, tt := range tests { 64 | t.Run(tt.name, func(t *testing.T) { 65 | nc := tt.nc() 66 | defer nc.Close() 67 | b := make([]byte, 1024) 68 | if tt.event.Event != nil { 69 | private, public, err := protocol.GetEncryptionKeys(nc.privateKey, tt.event.PubKey) 70 | if err != nil { 71 | panic(err) 72 | } 73 | sharedKey, err := nip44.GenerateConversationKey(private, public) 74 | if err != nil { 75 | panic(err) 76 | } 77 | fmt.Println(nip44.Encrypt(sharedKey, tt.event.Content, &nip44.EncryptOptions{})) 78 | } 79 | nc.subscriptionChan <- tt.event 80 | gotN, err := nc.Read(b) 81 | if (err != nil) != tt.wantErr { 82 | t.Errorf("Read() error = %v, wantErr %v", err, tt.wantErr) 83 | return 84 | } 85 | if gotN != tt.wantN { 86 | t.Errorf("Read() gotN = %v, want %v", gotN, tt.wantN) 87 | } 88 | }) 89 | } 90 | func() { 91 | // Prevent goroutine leak 92 | for range make([]struct{}, 1000) { 93 | runtime.Gosched() 94 | } 95 | }() 96 | } 97 | 98 | func TestNewConnection(t *testing.T) { 99 | testCases := []struct { 100 | name string 101 | opts []NostrConnOption 102 | expectedID string 103 | }{ 104 | { 105 | name: "NoOptions", 106 | }, 107 | { 108 | name: "WithPrivateKey", 109 | opts: []NostrConnOption{WithPrivateKey("privateKey")}, 110 | }, 111 | { 112 | name: "WithSub", 113 | opts: []NostrConnOption{WithSub(true)}, 114 | }, 115 | { 116 | name: "WithDst", 117 | opts: []NostrConnOption{WithDst("destination")}, 118 | }, 119 | { 120 | name: "WithUUID", 121 | opts: []NostrConnOption{WithUUID(uuid.New())}, 122 | }, 123 | } 124 | 125 | for _, tc := range testCases { 126 | t.Run(tc.name, func(t *testing.T) { 127 | ctx := context.Background() 128 | connection := NewConnection(ctx, tc.opts...) 129 | 130 | assert.NotNil(t, connection) 131 | assert.NotNil(t, connection.pool) 132 | assert.NotNil(t, connection.ctx) 133 | assert.NotNil(t, connection.cancel) 134 | assert.NotNil(t, connection.subscriptionChan) 135 | 136 | }) 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /socks5/auth.go: -------------------------------------------------------------------------------- 1 | package socks5 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | ) 7 | 8 | const ( 9 | NoAuth = uint8(0) 10 | noAcceptable = uint8(255) 11 | UserPassAuth = uint8(2) 12 | userAuthVersion = uint8(1) 13 | authSuccess = uint8(0) 14 | authFailure = uint8(1) 15 | ) 16 | 17 | var ( 18 | UserAuthFailed = fmt.Errorf("User authentication failed") 19 | NoSupportedAuth = fmt.Errorf("No supported authentication mechanism") 20 | ) 21 | 22 | // A Request encapsulates authentication state provided 23 | // during negotiation 24 | type AuthContext struct { 25 | // Provided auth method 26 | Method uint8 27 | // Payload provided during negotiation. 28 | // Keys depend on the used auth method. 29 | // For UserPassauth contains Username 30 | Payload map[string]string 31 | } 32 | 33 | type Authenticator interface { 34 | Authenticate(reader io.Reader, writer io.Writer) (*AuthContext, error) 35 | GetCode() uint8 36 | } 37 | 38 | // NoAuthAuthenticator is used to handle the "No Authentication" mode 39 | type NoAuthAuthenticator struct{} 40 | 41 | func (a NoAuthAuthenticator) GetCode() uint8 { 42 | return NoAuth 43 | } 44 | 45 | func (a NoAuthAuthenticator) Authenticate(reader io.Reader, writer io.Writer) (*AuthContext, error) { 46 | _, err := writer.Write([]byte{socks5Version, NoAuth}) 47 | return &AuthContext{NoAuth, nil}, err 48 | } 49 | 50 | // UserPassAuthenticator is used to handle username/password based 51 | // authentication 52 | type UserPassAuthenticator struct { 53 | Credentials CredentialStore 54 | } 55 | 56 | func (a UserPassAuthenticator) GetCode() uint8 { 57 | return UserPassAuth 58 | } 59 | 60 | func (a UserPassAuthenticator) Authenticate(reader io.Reader, writer io.Writer) (*AuthContext, error) { 61 | // Tell the client to use user/pass auth 62 | if _, err := writer.Write([]byte{socks5Version, UserPassAuth}); err != nil { 63 | return nil, err 64 | } 65 | 66 | // Get the version and username length 67 | header := []byte{0, 0} 68 | if _, err := io.ReadAtLeast(reader, header, 2); err != nil { 69 | return nil, err 70 | } 71 | 72 | // Ensure we are compatible 73 | if header[0] != userAuthVersion { 74 | return nil, fmt.Errorf("Unsupported auth version: %v", header[0]) 75 | } 76 | 77 | // Get the user name 78 | userLen := int(header[1]) 79 | user := make([]byte, userLen) 80 | if _, err := io.ReadAtLeast(reader, user, userLen); err != nil { 81 | return nil, err 82 | } 83 | 84 | // Get the password length 85 | if _, err := reader.Read(header[:1]); err != nil { 86 | return nil, err 87 | } 88 | 89 | // Get the password 90 | passLen := int(header[0]) 91 | pass := make([]byte, passLen) 92 | if _, err := io.ReadAtLeast(reader, pass, passLen); err != nil { 93 | return nil, err 94 | } 95 | 96 | // Verify the password 97 | if a.Credentials.Valid(string(user), string(pass)) { 98 | if _, err := writer.Write([]byte{userAuthVersion, authSuccess}); err != nil { 99 | return nil, err 100 | } 101 | } else { 102 | if _, err := writer.Write([]byte{userAuthVersion, authFailure}); err != nil { 103 | return nil, err 104 | } 105 | return nil, UserAuthFailed 106 | } 107 | 108 | // Done 109 | return &AuthContext{UserPassAuth, map[string]string{"Username": string(user)}}, nil 110 | } 111 | 112 | // authenticate is used to handle connection authentication 113 | func (s *Server) authenticate(conn io.Writer, bufConn io.Reader) (*AuthContext, error) { 114 | // Get the methods 115 | methods, err := readMethods(bufConn) 116 | if err != nil { 117 | return nil, fmt.Errorf("Failed to get auth methods: %v", err) 118 | } 119 | 120 | // Select a usable method 121 | for _, method := range methods { 122 | cator, found := s.authMethods[method] 123 | if found { 124 | return cator.Authenticate(bufConn, conn) 125 | } 126 | } 127 | 128 | // No usable method found 129 | return nil, noAcceptableAuth(conn) 130 | } 131 | 132 | // noAcceptableAuth is used to handle when we have no eligible 133 | // authentication mechanism 134 | func noAcceptableAuth(conn io.Writer) error { 135 | conn.Write([]byte{socks5Version, noAcceptable}) 136 | return NoSupportedAuth 137 | } 138 | 139 | // readMethods is used to read the number of methods 140 | // and proceeding auth methods 141 | func readMethods(r io.Reader) ([]byte, error) { 142 | header := []byte{0} 143 | if _, err := r.Read(header); err != nil { 144 | return nil, err 145 | } 146 | 147 | numMethods := int(header[0]) 148 | methods := make([]byte, numMethods) 149 | _, err := io.ReadAtLeast(r, methods, numMethods) 150 | return methods, err 151 | } 152 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # Nostr Web Services (NWS) 3 | 4 | NWS replaces the IP layer in TCP transport using Nostr, enabling secure connections between clients and backend services. 5 | 6 | Exit node [domain names](#nws-domain-names) make private services accessible to entry nodes. 7 | 8 | ### Prerequisites 9 | 10 | - A list of Nostr relays that the exit node is connected to. 11 | - The Nostr private key of the exit node. 12 | 13 | ## Overview 14 | 15 | ### NWS main components 16 | 17 | 1. **Exit node**: A TCP reverse proxy that listens for incoming Nostr subscriptions and forwards the payload to your designated backend service. 18 | 2. **Entry node**: A SOCKS5 proxy that forwards TCP packets and creates encrypted events for the exit node. 19 | 20 | 21 | 22 | ### NWS domain names 23 | 24 | There are two types of domain names resolved by NWS entry nodes: 25 | 1. `.nostr` domains, which have base32 encoded public key hostnames and base32 encoded relays as subdomains. 26 | 2. [nprofiles](https://nostr-nips.com/nip-19#shareable-identifiers-with-extra-metadata), which are combinations of a Nostr public key and multiple relays. 27 | 28 | Both types of domains will be generated and printed in the console on startup 29 | 30 | ## Quickstart 31 | 32 | Using Docker to run NWS is recommended. For instructions on running NWS on your local machine, refer to the [Build from source](#build-from-source) section. 33 | 34 | ### Using Docker-Compose 35 | 36 | Navigate to the `docker-compose.yaml` file and set `NOSTR_PRIVATE_KEY` to your private key. Leaving it empty will generate a new private key upon startup. 37 | 38 | To set up using Docker Compose, run the following command: 39 | ```bash 40 | docker compose up -d --build 41 | ``` 42 | 43 | This will start an example environment, including: 44 | - Entry node 45 | - Exit node 46 | - Exit node with HTTPS reverse proxy 47 | - [Cashu Nutshell](https://github.com/cashubtc/nutshell) (backend service) 48 | - [nostr-relay](https://github.com/hoytech/strfry) 49 | 50 | You can run the following commands to receive your NWS domain: 51 | 52 | ```bash 53 | docker logs exit-https 2>&1 | awk -F'domain=' '{if ($2) print $2}' | awk '{print $1}' 54 | ``` 55 | 56 | ```bash 57 | docker logs exit 2>&1 | awk -F'domain=' '{if ($2) print $2}' | awk '{print $1}' 58 | ``` 59 | 60 | ### Sending requests to the entry node 61 | 62 | With the log information from the previous step, you can use the following command to send a request to the exit node domain: 63 | 64 | ```bash 65 | curl -v -x socks5h://localhost:8882 http://"$(docker logs exit 2>&1 | awk -F'domain=' '{if ($2) print $2}' | awk '{print $1}' | tail -n 1)"/v1/info --insecure 66 | ``` 67 | 68 | If the exit node supports TLS, you can choose to connect using the HTTPS scheme: 69 | 70 | ```bash 71 | curl -v -x socks5h://localhost:8882 https://"$(docker logs exit-https 2>&1 | awk -F'domain=' '{if ($2) print $2}' | awk '{print $1}' | tail -n 1)"/v1/info --insecure 72 | ``` 73 | 74 | When using HTTPS, the entry node can be used as a service, as the operator will not be able to see the request data. 75 | 76 | ## Build from Source 77 | 78 | To make your services reachable via Nostr, set up the exit node. 79 | 80 | ### Exit node 81 | 82 | Configuration can be completed using environment variables. Alternatively, you can create a `.env` file in the current working directory with the following content: 83 | 84 | ``` 85 | NOSTR_RELAYS='ws://localhost:6666;ws://localhost:7777;wss://relay.domain.com' 86 | NOSTR_PRIVATE_KEY="EXITPRIVATEHEX" 87 | BACKEND_HOST='localhost:3338' 88 | PUBLIC=false 89 | ``` 90 | 91 | - `NOSTR_RELAYS`: A list of Nostr relays to publish events to. Used only if there is no relay data in the request. 92 | - `NOSTR_PRIVATE_KEY`: The private key to sign the events. 93 | - `BACKEND_HOST`: The host of the backend to forward requests to. 94 | - `PUBLIC`: If set to true, the exit node will announce itself on the Nostr network, enabling other entry nodes to discover it for public internet traffic relaying. 95 | 96 | To start the exit node, use this command: 97 | 98 | ```bash 99 | go run cmd/nws/nws.go exit 100 | ``` 101 | 102 | If your backend services support TLS, your service can now start using TLS encryption through a publicly available entry node. 103 | 104 | --- 105 | 106 | ### Entry node 107 | 108 | To run an entry node for accessing NWS services behind exit nodes, use the following command: 109 | 110 | ```bash 111 | go run cmd/nws/nws.go entry 112 | ``` 113 | 114 | If you don't want to use the `PUBLIC_ADDRESS` feature, no further configuration is needed. 115 | 116 | ``` 117 | PUBLIC_ADDRESS=':' 118 | ``` 119 | 120 | - `PUBLIC_ADDRESS`: This can be set if the entry node is publicly available. Exit node discovery will still be done using Nostr. Once a connection is established, this public address will be used to transmit further data. (`:`) 121 | - `NOSTR_RELAYS`: A list of Nostr relays to publish events to. Used only if there is no relay data in the request. 122 | -------------------------------------------------------------------------------- /strfry/strfry.conf: -------------------------------------------------------------------------------- 1 | ## 2 | ## Default strfry config 3 | ## 4 | 5 | # Directory that contains the strfry LMDB database (restart required) 6 | db = "./strfry-db/" 7 | 8 | dbParams { 9 | # Maximum number of threads/processes that can simultaneously have LMDB transactions open (restart required) 10 | maxreaders = 256 11 | 12 | # Size of mmap() to use when loading LMDB (default is 10TB, does *not* correspond to disk-space used) (restart required) 13 | mapsize = 10995116277760 14 | 15 | # Disables read-ahead when accessing the LMDB mapping. Reduces IO activity when DB size is larger than RAM. (restart required) 16 | noReadAhead = false 17 | } 18 | 19 | events { 20 | # Maximum size of normalised JSON, in bytes 21 | maxEventSize = 65536 22 | 23 | # Events newer than this will be rejected 24 | rejectEventsNewerThanSeconds = 900 25 | 26 | # Events older than this will be rejected 27 | rejectEventsOlderThanSeconds = 94608000 28 | 29 | # Ephemeral events older than this will be rejected 30 | rejectEphemeralEventsOlderThanSeconds = 60 31 | 32 | # Ephemeral events will be deleted from the DB when older than this 33 | ephemeralEventsLifetimeSeconds = 300 34 | 35 | # Maximum number of tags allowed 36 | maxNumTags = 2000 37 | 38 | # Maximum size for tag values, in bytes 39 | maxTagValSize = 1024 40 | } 41 | 42 | relay { 43 | # Interface to listen on. Use 0.0.0.0 to listen on all interfaces (restart required) 44 | bind = "0.0.0.0" 45 | 46 | # Port to open for the nostr websocket protocol (restart required) 47 | port = 7777 48 | 49 | # Set OS-limit on maximum number of open files/sockets (if 0, don't attempt to set) (restart required) 50 | nofiles = 1000000 51 | 52 | # HTTP header that contains the client's real IP, before reverse proxying (ie x-real-ip) (MUST be all lower-case) 53 | realIpHeader = "" 54 | 55 | info { 56 | # NIP-11: Name of this server. Short/descriptive (< 30 characters) 57 | name = "strfry default" 58 | 59 | # NIP-11: Detailed information about relay, free-form 60 | description = "This is a strfry instance." 61 | 62 | # NIP-11: Administrative nostr pubkey, for contact purposes 63 | pubkey = "" 64 | 65 | # NIP-11: Alternative administrative contact (email, website, etc) 66 | contact = "" 67 | 68 | # NIP-11: URL pointing to an image to be used as an icon for the relay 69 | icon = "" 70 | } 71 | 72 | # Maximum accepted incoming websocket frame size (should be larger than max event) (restart required) 73 | maxWebsocketPayloadSize = 131072 74 | 75 | # Websocket-level PING message frequency (should be less than any reverse proxy idle timeouts) (restart required) 76 | autoPingSeconds = 55 77 | 78 | # If TCP keep-alive should be enabled (detect dropped connections to upstream reverse proxy) 79 | enableTcpKeepalive = false 80 | 81 | # How much uninterrupted CPU time a REQ query should get during its DB scan 82 | queryTimesliceBudgetMicroseconds = 10000 83 | 84 | # Maximum records that can be returned per filter 85 | maxFilterLimit = 500 86 | 87 | # Maximum number of subscriptions (concurrent REQs) a connection can have open at any time 88 | maxSubsPerConnection = 20 89 | 90 | writePolicy { 91 | # If non-empty, path to an executable script that implements the writePolicy plugin logic 92 | plugin = "" 93 | } 94 | 95 | compression { 96 | # Use permessage-deflate compression if supported by client. Reduces bandwidth, but slight increase in CPU (restart required) 97 | enabled = true 98 | 99 | # Maintain a sliding window buffer for each connection. Improves compression, but uses more memory (restart required) 100 | slidingWindow = true 101 | } 102 | 103 | logging { 104 | # Dump all incoming messages 105 | dumpInAll = false 106 | 107 | # Dump all incoming EVENT messages 108 | dumpInEvents = false 109 | 110 | # Dump all incoming REQ/CLOSE messages 111 | dumpInReqs = false 112 | 113 | # Log performance metrics for initial REQ database scans 114 | dbScanPerf = false 115 | 116 | # Log reason for invalid event rejection? Can be disabled to silence excessive logging 117 | invalidEvents = true 118 | } 119 | 120 | numThreads { 121 | # Ingester threads: route incoming requests, validate events/sigs (restart required) 122 | ingester = 3 123 | 124 | # reqWorker threads: Handle initial DB scan for events (restart required) 125 | reqWorker = 3 126 | 127 | # reqMonitor threads: Handle filtering of new events (restart required) 128 | reqMonitor = 3 129 | 130 | # negentropy threads: Handle negentropy protocol messages (restart required) 131 | negentropy = 2 132 | } 133 | 134 | negentropy { 135 | # Support negentropy protocol messages 136 | enabled = true 137 | 138 | # Maximum records that sync will process before returning an error 139 | maxSyncEvents = 1000000 140 | } 141 | } -------------------------------------------------------------------------------- /socks5/socks5.go: -------------------------------------------------------------------------------- 1 | package socks5 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "github.com/asmogo/nws/config" 7 | "github.com/nbd-wtf/go-nostr" 8 | "log" 9 | "net" 10 | "os" 11 | 12 | "context" 13 | ) 14 | 15 | const ( 16 | socks5Version = uint8(5) 17 | ) 18 | 19 | // Config is used to setup and configure a Server 20 | type Config struct { 21 | // AuthMethods can be provided to implement custom authentication 22 | // By default, "auth-less" mode is enabled. 23 | // For password-based auth use UserPassAuthenticator. 24 | AuthMethods []Authenticator 25 | 26 | // If provided, username/password authentication is enabled, 27 | // by appending a UserPassAuthenticator to AuthMethods. If not provided, 28 | // and AUthMethods is nil, then "auth-less" mode is enabled. 29 | Credentials CredentialStore 30 | 31 | // Resolver can be provided to do custom name resolution. 32 | // Defaults to DNSResolver if not provided. 33 | Resolver NameResolver 34 | 35 | // Rules is provided to enable custom logic around permitting 36 | // various commands. If not provided, PermitAll is used. 37 | Rules RuleSet 38 | 39 | // Rewriter can be used to transparently rewrite addresses. 40 | // This is invoked before the RuleSet is invoked. 41 | // Defaults to NoRewrite. 42 | Rewriter AddressRewriter 43 | 44 | // BindIP is used for bind or udp associate 45 | BindIP net.IP 46 | 47 | // Logger can be used to provide a custom log target. 48 | // Defaults to stdout. 49 | Logger *log.Logger 50 | 51 | // Optional function for dialing out 52 | Dial func(ctx context.Context, network, addr string) (net.Conn, error) 53 | 54 | entryConfig *config.EntryConfig 55 | } 56 | 57 | var ErrorNoServerAvailable = fmt.Errorf("no socks server available") 58 | 59 | // Server is reponsible for accepting connections and handling 60 | // the details of the SOCKS5 protocol 61 | type Server struct { 62 | config *Config 63 | authMethods map[uint8]Authenticator 64 | pool *nostr.SimplePool 65 | tcpListener *TCPListener 66 | } 67 | 68 | // New creates a new Server and potentially returns an error 69 | func New(conf *Config, pool *nostr.SimplePool, config *config.EntryConfig) (*Server, error) { 70 | // Ensure we have at least one authentication method enabled 71 | if len(conf.AuthMethods) == 0 { 72 | if conf.Credentials != nil { 73 | conf.AuthMethods = []Authenticator{&UserPassAuthenticator{conf.Credentials}} 74 | } else { 75 | conf.AuthMethods = []Authenticator{&NoAuthAuthenticator{}} 76 | } 77 | } 78 | 79 | // Ensure we have a DNS resolver 80 | if conf.Resolver == nil { 81 | conf.Resolver = DNSResolver{} 82 | } 83 | 84 | // Ensure we have a rule set 85 | if conf.Rules == nil { 86 | conf.Rules = PermitAll() 87 | } 88 | 89 | // Ensure we have a log target 90 | if conf.Logger == nil { 91 | conf.Logger = log.New(os.Stdout, "", log.LstdFlags) 92 | } 93 | if conf.entryConfig == nil { 94 | conf.entryConfig = config 95 | } 96 | 97 | server := &Server{ 98 | config: conf, 99 | pool: pool, 100 | } 101 | if conf.entryConfig.PublicAddress != "" { 102 | // parse host port 103 | _, port, err := net.SplitHostPort(conf.entryConfig.PublicAddress) 104 | if err != nil { 105 | return nil, fmt.Errorf("failed to parse public address: %w", err) 106 | } 107 | listener, err := NewTCPListener(net.JoinHostPort(net.IP{0, 0, 0, 0}.String(), port)) 108 | if err != nil { 109 | return nil, fmt.Errorf("failed to create tcp listener: %w", err) 110 | } 111 | go listener.Start() 112 | server.tcpListener = listener 113 | } 114 | server.authMethods = make(map[uint8]Authenticator) 115 | 116 | for _, a := range conf.AuthMethods { 117 | server.authMethods[a.GetCode()] = a 118 | } 119 | 120 | return server, nil 121 | } 122 | 123 | func (s *Server) Configuration() (*Config, error) { 124 | if s.config != nil { 125 | return s.config, nil 126 | } 127 | return nil, fmt.Errorf("socks: configuration not set yet") 128 | } 129 | 130 | // ListenAndServe is used to create a listener and serve on it 131 | func (s *Server) ListenAndServe(network, port string) error { 132 | bind := net.JoinHostPort(s.config.BindIP.String(), port) 133 | l, err := net.Listen(network, bind) 134 | if err != nil { 135 | return err 136 | } 137 | return s.Serve(l) 138 | } 139 | 140 | // Serve is used to serve connections from a listener 141 | func (s *Server) Serve(l net.Listener) error { 142 | for { 143 | conn, err := l.Accept() 144 | if err != nil { 145 | return err 146 | } 147 | go s.ServeConn(conn) 148 | } 149 | return nil 150 | } 151 | 152 | // GetAuthContext is used to retrieve the auth context from connection 153 | func (s *Server) GetAuthContext(conn net.Conn, bufConn *bufio.Reader) (*AuthContext, error) { 154 | // Read the version byte 155 | version := []byte{0} 156 | if _, err := bufConn.Read(version); err != nil { 157 | s.config.Logger.Printf("[ERR] socks: Failed to get version byte: %v", err) 158 | return nil, err 159 | } 160 | 161 | // Ensure we are compatible 162 | if version[0] != socks5Version { 163 | err := fmt.Errorf("Unsupported SOCKS version: %v", version) 164 | s.config.Logger.Printf("[ERR] socks: %v", err) 165 | return nil, err 166 | } 167 | 168 | // Authenticate the connection 169 | authContext, err := s.authenticate(conn, bufConn) 170 | if err != nil { 171 | err = fmt.Errorf("Failed to authenticate: %v", err) 172 | s.config.Logger.Printf("[ERR] socks: %v", err) 173 | return nil, err 174 | } 175 | return authContext, nil 176 | } 177 | 178 | // GetRequest is used to retrieve Request from connection 179 | func (s *Server) GetRequest(conn net.Conn, bufConn *bufio.Reader) (*Request, error) { 180 | request, err := NewRequest(bufConn) 181 | if err != nil { 182 | if err == unrecognizedAddrType { 183 | if err := SendReply(conn, addrTypeNotSupported, nil); err != nil { 184 | return nil, fmt.Errorf("Failed to send reply: %v", err) 185 | } 186 | } 187 | return nil, fmt.Errorf("Failed to read destination address: %v", err) 188 | } 189 | return request, nil 190 | } 191 | 192 | // ServeConn is used to serve a single connection. 193 | func (s *Server) ServeConn(conn net.Conn) error { 194 | s.config.Logger.Print("[INFO] serving socks5 connection") 195 | defer conn.Close() 196 | bufConn := bufio.NewReader(conn) 197 | authContext, err := s.GetAuthContext(conn, bufConn) 198 | if err != nil { 199 | return err 200 | } 201 | request, err := s.GetRequest(conn, bufConn) 202 | if err != nil { 203 | return err 204 | } 205 | 206 | request.AuthContext = authContext 207 | if client, ok := conn.RemoteAddr().(*net.TCPAddr); ok { 208 | request.RemoteAddr = &AddrSpec{IP: client.IP, Port: client.Port} 209 | } 210 | s.config.Logger.Printf("[INFO] handling request from %s", request.RemoteAddr.IP) 211 | // Process the client request 212 | if err := s.handleRequest(request, conn); err != nil { 213 | err = fmt.Errorf("failed to handle request: %v", err) 214 | s.config.Logger.Printf("[ERR] socks: %v", err) 215 | return err 216 | } 217 | 218 | return nil 219 | } 220 | -------------------------------------------------------------------------------- /exit/https.go: -------------------------------------------------------------------------------- 1 | package exit 2 | 3 | import ( 4 | "context" 5 | "crypto/rand" 6 | "crypto/rsa" 7 | "crypto/tls" 8 | "crypto/x509" 9 | "crypto/x509/pkix" 10 | "encoding/pem" 11 | "errors" 12 | "fmt" 13 | "log/slog" 14 | "math/big" 15 | "net/http" 16 | "net/http/httputil" 17 | "net/url" 18 | "os" 19 | "time" 20 | 21 | "github.com/asmogo/nws/protocol" 22 | "github.com/ekzyis/nip44" 23 | "github.com/nbd-wtf/go-nostr" 24 | ) 25 | 26 | const ( 27 | headerTimeout = 5 * time.Second 28 | ) 29 | 30 | var ( 31 | errNoCertificateEvent = errors.New("failed to find encrypted direct message") 32 | ) 33 | 34 | func (e *Exit) StartReverseProxy(ctx context.Context, httpTarget string, port int32) error { 35 | incomingEvent := e.pool.QuerySingle(ctx, e.config.NostrRelays, nostr.Filter{ 36 | Authors: []string{e.publicKey}, 37 | Kinds: []int{protocol.KindCertificateEvent}, 38 | Tags: nostr.TagMap{"p": []string{e.publicKey}}, 39 | }) 40 | var cert tls.Certificate 41 | var err error 42 | if incomingEvent == nil { 43 | certificate, err := e.createAndStoreCertificateData(ctx) 44 | if err != nil { 45 | return err 46 | } 47 | cert = *certificate 48 | } else { 49 | cert, err = e.handleCertificateEvent(incomingEvent, ctx, cert) 50 | if err != nil { 51 | return err 52 | } 53 | } 54 | target, _ := url.Parse(httpTarget) 55 | 56 | httpsConfig := &http.Server{ 57 | ReadHeaderTimeout: headerTimeout, 58 | Addr: fmt.Sprintf(":%d", port), 59 | TLSConfig: &tls.Config{Certificates: []tls.Certificate{cert}}, 60 | Handler: http.HandlerFunc(httputil.NewSingleHostReverseProxy(target).ServeHTTP), 61 | } 62 | return httpsConfig.ListenAndServeTLS("", "") 63 | 64 | } 65 | 66 | func (e *Exit) handleCertificateEvent( 67 | incomingEvent *nostr.IncomingEvent, 68 | ctx context.Context, 69 | cert tls.Certificate, 70 | ) (tls.Certificate, error) { 71 | slog.Info("found certificate event", "certificate", incomingEvent.Content) 72 | // load private key from file 73 | privateKeyEvent := e.pool.QuerySingle(ctx, e.config.NostrRelays, nostr.Filter{ 74 | Authors: []string{e.publicKey}, 75 | Kinds: []int{protocol.KindPrivateKeyEvent}, 76 | Tags: nostr.TagMap{"p": []string{e.publicKey}}, 77 | }) 78 | if privateKeyEvent == nil { 79 | return tls.Certificate{}, errNoCertificateEvent 80 | } 81 | privateKeyBytes, targetPublicKeyBytes, err := protocol.GetEncryptionKeys(e.config.NostrPrivateKey, privateKeyEvent.PubKey) 82 | if err != nil { 83 | return tls.Certificate{}, err 84 | } 85 | sharedKey, err := nip44.GenerateConversationKey(privateKeyBytes, targetPublicKeyBytes) 86 | if err != nil { 87 | return tls.Certificate{}, fmt.Errorf("failed to compute shared key: %w", err) 88 | } 89 | decodedMessage, err := nip44.Decrypt(sharedKey, privateKeyEvent.Content) 90 | if err != nil { 91 | return tls.Certificate{}, fmt.Errorf("failed to decrypt private key: %w", err) 92 | } 93 | message, err := protocol.UnmarshalJSON([]byte(decodedMessage)) 94 | if err != nil { 95 | return tls.Certificate{}, fmt.Errorf("failed to unmarshal message: %w", err) 96 | } 97 | block, _ := pem.Decode(message.Data) 98 | if block == nil { 99 | _, err = fmt.Fprintf(os.Stderr, "error: failed to decode PEM block containing private key\n") 100 | if err != nil { 101 | return tls.Certificate{}, fmt.Errorf("failed to write error: %w", err) 102 | } 103 | os.Exit(1) 104 | } 105 | 106 | if got, want := block.Type, "RSA PRIVATE KEY"; got != want { 107 | _, err = fmt.Fprintf(os.Stderr, "error: decoded PEM block of type %s, but wanted %s", got, want) 108 | if err != nil { 109 | return tls.Certificate{}, fmt.Errorf("failed to write error: %w", err) 110 | } 111 | os.Exit(1) 112 | } 113 | 114 | priv, err := x509.ParsePKCS1PrivateKey(block.Bytes) 115 | if err != nil { 116 | return tls.Certificate{}, fmt.Errorf("failed to parse private key: %w", err) 117 | } 118 | certBlock, _ := pem.Decode([]byte(incomingEvent.Content)) 119 | if certBlock == nil { 120 | _, err = fmt.Fprintf(os.Stderr, "Failed to parse certificate PEM.") 121 | if err != nil { 122 | return tls.Certificate{}, fmt.Errorf("failed to write error: %w", err) 123 | } 124 | os.Exit(1) 125 | } 126 | 127 | parsedCert, err := x509.ParseCertificate(certBlock.Bytes) 128 | if err != nil { 129 | return tls.Certificate{}, fmt.Errorf("failed to parse certificate: %w", err) 130 | } 131 | cert = tls.Certificate{ 132 | Certificate: [][]byte{certBlock.Bytes}, 133 | PrivateKey: priv, 134 | Leaf: parsedCert, 135 | } 136 | return cert, nil 137 | } 138 | 139 | const ( 140 | tenYears = 0 * 365 * 24 * time.Hour 141 | keySize = 2048 142 | limit = 128 143 | chmod = 0644 144 | ) 145 | 146 | func (e *Exit) createAndStoreCertificateData(ctx context.Context) (*tls.Certificate, error) { 147 | priv, _ := rsa.GenerateKey(rand.Reader, keySize) 148 | notBefore := time.Now() 149 | notAfter := notBefore.Add(tenYears) 150 | serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), limit) 151 | serialNumber, _ := rand.Int(rand.Reader, serialNumberLimit) 152 | domain, _ := e.getDomain() 153 | 154 | template := x509.Certificate{ 155 | SerialNumber: serialNumber, 156 | Subject: pkix.Name{ 157 | Organization: []string{"NWS"}, 158 | }, 159 | NotBefore: notBefore, 160 | NotAfter: notAfter, 161 | KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, 162 | ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, 163 | BasicConstraintsValid: true, 164 | DNSNames: []string{domain}, 165 | } 166 | 167 | certBytes, _ := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv) 168 | certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certBytes}) 169 | keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(priv)}) 170 | // save key pem to file 171 | err := os.WriteFile(fmt.Sprintf("%s.key", e.publicKey), keyPEM, chmod) 172 | if err != nil { 173 | return nil, err 174 | } 175 | cert, _ := tls.X509KeyPair(certPEM, keyPEM) 176 | certificate, err := e.storeCertificate(ctx, certPEM) 177 | if err != nil { 178 | return certificate, err 179 | } 180 | err = e.storePrivateKey(ctx, keyPEM) 181 | if err != nil { 182 | return certificate, err 183 | } 184 | return &cert, nil 185 | } 186 | 187 | func (e *Exit) storePrivateKey(ctx context.Context, keyPEM []byte) error { 188 | s, err := protocol.NewEventSigner(e.config.NostrPrivateKey) 189 | if err != nil { 190 | return err 191 | } 192 | event, err := s.CreateSignedEvent(e.publicKey, protocol.KindPrivateKeyEvent, nostr.Tags{ 193 | nostr.Tag{"p", e.publicKey}, 194 | }, protocol.WithData(keyPEM)) 195 | if err != nil { 196 | return err 197 | } 198 | for _, responseRelay := range e.config.NostrRelays { 199 | var relay *nostr.Relay 200 | relay, err = e.pool.EnsureRelay(responseRelay) 201 | if err != nil { 202 | return err 203 | } 204 | err = relay.Publish(ctx, event) 205 | if err != nil { 206 | return err 207 | } 208 | } 209 | return nil 210 | } 211 | func (e *Exit) storeCertificate(ctx context.Context, certPEM []byte) (*tls.Certificate, error) { 212 | event := nostr.Event{ 213 | CreatedAt: nostr.Now(), 214 | PubKey: e.publicKey, 215 | Kind: protocol.KindCertificateEvent, 216 | Content: string(certPEM), 217 | Tags: nostr.Tags{ 218 | nostr.Tag{"p", e.publicKey}, 219 | }, 220 | } 221 | err := event.Sign(e.config.NostrPrivateKey) 222 | if err != nil { 223 | return nil, err 224 | } 225 | for _, responseRelay := range e.config.NostrRelays { 226 | var relay *nostr.Relay 227 | relay, err = e.pool.EnsureRelay(responseRelay) 228 | if err != nil { 229 | return nil, err 230 | } 231 | err = relay.Publish(ctx, event) 232 | if err != nil { 233 | return nil, err 234 | } 235 | } 236 | return nil, nil 237 | } 238 | -------------------------------------------------------------------------------- /socks5/request.go: -------------------------------------------------------------------------------- 1 | package socks5 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io" 7 | "net" 8 | "strconv" 9 | "strings" 10 | "time" 11 | 12 | "github.com/asmogo/nws/netstr" 13 | "github.com/asmogo/nws/protocol" 14 | "github.com/google/uuid" 15 | ) 16 | 17 | const ( 18 | ConnectCommand = uint8(1) 19 | BindCommand = uint8(2) 20 | AssociateCommand = uint8(3) 21 | ipv4Address = uint8(1) 22 | fqdnAddress = uint8(3) 23 | ipv6Address = uint8(4) 24 | ) 25 | 26 | const ( 27 | successReply uint8 = iota 28 | serverFailure 29 | ruleFailure 30 | networkUnreachable 31 | hostUnreachable 32 | connectionRefused 33 | ttlExpired 34 | commandNotSupported 35 | addrTypeNotSupported 36 | ) 37 | 38 | var ( 39 | unrecognizedAddrType = fmt.Errorf("unrecognized address type") 40 | ) 41 | 42 | // AddressRewriter is used to rewrite a destination transparently 43 | type AddressRewriter interface { 44 | Rewrite(ctx context.Context, request *Request) (context.Context, *AddrSpec) 45 | } 46 | 47 | // AddrSpec is used to return the target AddrSpec 48 | // which may be specified as IPv4, IPv6, or a FQDN 49 | type AddrSpec struct { 50 | FQDN string 51 | IP net.IP 52 | Port int 53 | } 54 | 55 | func (a *AddrSpec) String() string { 56 | if a.FQDN != "" { 57 | return fmt.Sprintf("%s (%s):%d", a.FQDN, a.IP, a.Port) 58 | } 59 | return fmt.Sprintf("%s:%d", a.IP, a.Port) 60 | } 61 | 62 | // Address returns a string suitable to dial; prefer returning IP-based 63 | // address, fallback to FQDN 64 | func (a AddrSpec) Address() string { 65 | if a.IP == nil { 66 | return a.FQDN 67 | } 68 | if 0 != len(a.IP) { 69 | return net.JoinHostPort(a.IP.String(), strconv.Itoa(a.Port)) 70 | } 71 | return net.JoinHostPort(a.FQDN, strconv.Itoa(a.Port)) 72 | } 73 | 74 | // A Request represents request received by a server 75 | type Request struct { 76 | // Protocol version 77 | Version uint8 78 | // Requested command 79 | Command uint8 80 | // AuthContext provided during negotiation 81 | AuthContext *AuthContext 82 | // AddrSpec of the the network that sent the request 83 | RemoteAddr *AddrSpec 84 | // AddrSpec of the desired destination 85 | DestAddr *AddrSpec 86 | // AddrSpec of the actual destination (might be affected by rewrite) 87 | realDestAddr *AddrSpec 88 | } 89 | 90 | /* 91 | type conn interface { 92 | Write([]byte) (int, error) 93 | RemoteAddr() net.Addr 94 | } 95 | */ 96 | // NewRequest creates a new Request from the tcp connection 97 | func NewRequest(bufConn io.Reader) (*Request, error) { 98 | // Read the version byte 99 | header := []byte{0, 0, 0} 100 | if _, err := io.ReadAtLeast(bufConn, header, 3); err != nil { 101 | return nil, fmt.Errorf("failed to get command version: %w", err) 102 | } 103 | 104 | // Ensure we are compatible 105 | if header[0] != socks5Version { 106 | return nil, fmt.Errorf("Unsupported command version: %v", header[0]) 107 | } 108 | 109 | // Read in the destination address 110 | dest, err := readAddrSpec(bufConn) 111 | if err != nil { 112 | return nil, err 113 | } 114 | 115 | request := &Request{ 116 | Version: socks5Version, 117 | Command: header[1], 118 | DestAddr: dest, 119 | } 120 | return request, nil 121 | } 122 | 123 | // handleRequest is used for request processing after authentication 124 | func (s *Server) handleRequest(req *Request, conn net.Conn) error { 125 | ctx := context.Background() 126 | 127 | // Resolve the address if we have a FQDN 128 | dest := req.DestAddr 129 | var targetPublicKey string 130 | if dest.FQDN != "" { 131 | ctx_, addr, err := s.config.Resolver.Resolve(ctx, dest.FQDN) 132 | if err != nil { 133 | if err := SendReply(conn, hostUnreachable, nil); err != nil { 134 | return fmt.Errorf("failed to send reply: %w", err) 135 | } 136 | return fmt.Errorf("failed to resolve destination '%v': %w", dest.FQDN, err) 137 | } 138 | ctx = ctx_ 139 | dest.IP = addr 140 | if pubKey := ctx.Value(netstr.TargetPublicKey); pubKey != nil { 141 | var ok bool 142 | if targetPublicKey, ok = pubKey.(string); !ok { 143 | return fmt.Errorf("failed to get target public key: %w", err) 144 | } 145 | } 146 | } 147 | 148 | // Apply any address rewrites 149 | req.realDestAddr = req.DestAddr 150 | if s.config.Rewriter != nil { 151 | ctx, req.realDestAddr = s.config.Rewriter.Rewrite(ctx, req) 152 | } 153 | 154 | // Switch on the command 155 | switch req.Command { 156 | case ConnectCommand: 157 | options := netstr.DialOptions{ 158 | Pool: s.pool, 159 | PublicAddress: s.config.entryConfig.PublicAddress, 160 | ConnectionID: uuid.New(), 161 | TargetPublicKey: targetPublicKey, 162 | } 163 | return s.handleConnect(ctx, conn, req, options) 164 | case BindCommand: 165 | return s.handleBind(ctx, conn, req) 166 | case AssociateCommand: 167 | return s.handleAssociate(ctx, conn, req) 168 | default: 169 | if err := SendReply(conn, commandNotSupported, nil); err != nil { 170 | return fmt.Errorf("failed to send reply: %w", err) 171 | } 172 | return fmt.Errorf("unsupported command: %d", req.Command) 173 | } 174 | } 175 | 176 | // handleConnect is used to handle a connect command 177 | func (s *Server) handleConnect(ctx context.Context, conn net.Conn, req *Request, options netstr.DialOptions) error { 178 | // Check if this is allowed 179 | if ctx_, ok := s.config.Rules.Allow(ctx, req); !ok { 180 | if err := SendReply(conn, ruleFailure, nil); err != nil { 181 | return fmt.Errorf("failed to send reply: %w", err) 182 | } 183 | return fmt.Errorf("connect to %v blocked by rules", req.DestAddr) 184 | } else { 185 | ctx = ctx_ 186 | } 187 | ch := make(chan net.Conn) 188 | // Attempt to connect 189 | 190 | dial := s.config.Dial 191 | if dial == nil { 192 | if s.tcpListener != nil { 193 | s.tcpListener.AddConnectChannel(options.ConnectionID, ch) 194 | options.MessageType = protocol.MessageConnectReverse 195 | } else { 196 | options.MessageType = protocol.MessageConnect 197 | } 198 | 199 | dial = netstr.DialSocks(options, s.config.entryConfig) 200 | } 201 | ctx, _ = context.WithTimeout(context.Background(), time.Second*10) 202 | target, err := dial(ctx, "tcp", req.realDestAddr.Address()) 203 | if err != nil { 204 | msg := err.Error() 205 | resp := hostUnreachable 206 | if strings.Contains(msg, "refused") { 207 | resp = connectionRefused 208 | } else if strings.Contains(msg, "network is unreachable") { 209 | resp = networkUnreachable 210 | } 211 | if err := SendReply(conn, resp, nil); err != nil { 212 | return fmt.Errorf("failed to send reply: %v", err) 213 | } 214 | return fmt.Errorf("connect to %v failed: %v", req.DestAddr, err) 215 | } 216 | defer target.Close() 217 | 218 | // Send success 219 | local := target.LocalAddr().(*net.TCPAddr) 220 | bind := AddrSpec{IP: local.IP, Port: local.Port} 221 | if err := SendReply(conn, successReply, &bind); err != nil { 222 | return fmt.Errorf("failed to send reply: %w", err) 223 | } 224 | // read 225 | if options.MessageType == protocol.MessageConnectReverse { 226 | // wait for the connection 227 | // in this case, our target needs to be the reversed tcp connection 228 | target = <-ch 229 | defer target.Close() 230 | } 231 | // Start proxying 232 | errCh := make(chan error, 2) 233 | go Proxy(target, conn, errCh) 234 | go Proxy(conn, target, errCh) 235 | 236 | // Wait 237 | for i := 0; i < 2; i++ { 238 | e := <-errCh 239 | if e != nil { 240 | // return from this function closes target (and conn). 241 | return e 242 | } 243 | } 244 | return nil 245 | } 246 | 247 | // handleBind is used to handle a connect command 248 | func (s *Server) handleBind(ctx context.Context, conn net.Conn, req *Request) error { 249 | // Check if this is allowed 250 | if ctx_, ok := s.config.Rules.Allow(ctx, req); !ok { 251 | if err := SendReply(conn, ruleFailure, nil); err != nil { 252 | return fmt.Errorf("failed to send reply: %w", err) 253 | } 254 | return fmt.Errorf("Bind to %v blocked by rules", req.DestAddr) 255 | } else { 256 | ctx = ctx_ 257 | } 258 | 259 | // TODO: Support bind 260 | if err := SendReply(conn, commandNotSupported, nil); err != nil { 261 | return fmt.Errorf("failed to send reply: %w", err) 262 | } 263 | return nil 264 | } 265 | 266 | // handleAssociate is used to handle a connect command 267 | func (s *Server) handleAssociate(ctx context.Context, conn net.Conn, req *Request) error { 268 | // Check if this is allowed 269 | if ctx_, ok := s.config.Rules.Allow(ctx, req); !ok { 270 | if err := SendReply(conn, ruleFailure, nil); err != nil { 271 | return fmt.Errorf("failed to send reply: %w", err) 272 | } 273 | return fmt.Errorf("associate to %v blocked by rules", req.DestAddr) 274 | } else { 275 | ctx = ctx_ 276 | } 277 | 278 | // TODO: Support associate 279 | if err := SendReply(conn, commandNotSupported, nil); err != nil { 280 | return fmt.Errorf("failed to send reply: %w", err) 281 | } 282 | return nil 283 | } 284 | 285 | // readAddrSpec is used to read AddrSpec. 286 | // Expects an address type byte, follwed by the address and port 287 | func readAddrSpec(r io.Reader) (*AddrSpec, error) { 288 | d := &AddrSpec{} 289 | 290 | // Get the address type 291 | addrType := []byte{0} 292 | if _, err := r.Read(addrType); err != nil { 293 | return nil, err 294 | } 295 | 296 | // Handle on a per type basis 297 | switch addrType[0] { 298 | case ipv4Address: 299 | addr := make([]byte, 4) 300 | if _, err := io.ReadAtLeast(r, addr, len(addr)); err != nil { 301 | return nil, err 302 | } 303 | d.IP = net.IP(addr) 304 | 305 | case ipv6Address: 306 | addr := make([]byte, 16) 307 | if _, err := io.ReadAtLeast(r, addr, len(addr)); err != nil { 308 | return nil, err 309 | } 310 | d.IP = net.IP(addr) 311 | 312 | case fqdnAddress: 313 | if _, err := r.Read(addrType); err != nil { 314 | return nil, err 315 | } 316 | addrLen := int(addrType[0]) 317 | fqdn := make([]byte, addrLen) 318 | if _, err := io.ReadAtLeast(r, fqdn, addrLen); err != nil { 319 | return nil, err 320 | } 321 | d.FQDN = string(fqdn) 322 | 323 | default: 324 | return nil, unrecognizedAddrType 325 | } 326 | 327 | // Read the port 328 | port := []byte{0, 0} 329 | if _, err := io.ReadAtLeast(r, port, 2); err != nil { 330 | return nil, err 331 | } 332 | d.Port = (int(port[0]) << 8) | int(port[1]) 333 | 334 | return d, nil 335 | } 336 | 337 | // SendReply is used to send a reply message 338 | func SendReply(w io.Writer, resp uint8, addr *AddrSpec) error { 339 | // Format the address 340 | var addrType uint8 341 | var addrBody []byte 342 | var addrPort uint16 343 | switch { 344 | case addr == nil: 345 | addrType = ipv4Address 346 | addrBody = []byte{0, 0, 0, 0} 347 | addrPort = 0 348 | 349 | case addr.FQDN != "": 350 | addrType = fqdnAddress 351 | addrBody = append([]byte{byte(len(addr.FQDN))}, addr.FQDN...) 352 | addrPort = uint16(addr.Port) 353 | 354 | case addr.IP.To4() != nil: 355 | addrType = ipv4Address 356 | addrBody = []byte(addr.IP.To4()) 357 | addrPort = uint16(addr.Port) 358 | 359 | case addr.IP.To16() != nil: 360 | addrType = ipv6Address 361 | addrBody = []byte(addr.IP.To16()) 362 | addrPort = uint16(addr.Port) 363 | 364 | default: 365 | return fmt.Errorf("failed to format address: %v", addr) 366 | } 367 | 368 | // Format the message 369 | msg := make([]byte, 6+len(addrBody)) 370 | msg[0] = socks5Version 371 | msg[1] = resp 372 | msg[2] = 0 // Reserved 373 | msg[3] = addrType 374 | copy(msg[4:], addrBody) 375 | msg[4+len(addrBody)] = byte(addrPort >> 8) 376 | msg[4+len(addrBody)+1] = byte(addrPort & 0xff) 377 | 378 | // Send the message 379 | _, err := w.Write(msg) 380 | if err != nil { 381 | return fmt.Errorf("failed to send reply: %w", err) 382 | } 383 | return nil 384 | } 385 | 386 | type closeWriter interface { 387 | CloseWrite() error 388 | } 389 | 390 | // Proxy is used to shuffle data from src to destination, and sends errors 391 | // down a dedicated channel 392 | func Proxy(dst io.Writer, src io.Reader, errCh chan error) { 393 | _, err := io.Copy(dst, src) 394 | checkError(errCh, err) 395 | if tcpConn, ok := dst.(closeWriter); ok { 396 | err = tcpConn.CloseWrite() 397 | } 398 | if conn, ok := dst.(io.Closer); ok { 399 | err = conn.Close() 400 | } 401 | if conn, ok := src.(io.Closer); ok { 402 | err = conn.Close() 403 | } 404 | checkError(errCh, err) 405 | } 406 | 407 | func checkError(errCh chan error, err error) { 408 | if errCh != nil { 409 | errCh <- err 410 | } 411 | } 412 | -------------------------------------------------------------------------------- /protocol/domain.go: -------------------------------------------------------------------------------- 1 | package protocol 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "net" 7 | "net/url" 8 | "strings" 9 | 10 | "golang.org/x/net/idna" 11 | "golang.org/x/net/publicsuffix" 12 | ) 13 | 14 | const ( 15 | ipV6URINotationPrefix = "[" 16 | ipV6URINotationSuffix = "]" 17 | ) 18 | 19 | var ErrEmptyURL = errors.New("url to be parsed is empty") 20 | 21 | // URL represents a URL with additional fields and methods. 22 | type URL struct { 23 | SubName, Name, TLD, Port string 24 | IsDomain bool 25 | *url.URL 26 | } 27 | 28 | // String returns the string representation of the URL. 29 | // It includes the scheme if `includeScheme` is true. 30 | func (url URL) String(includeScheme bool) string { 31 | s := url.URL.String() 32 | if !includeScheme { 33 | s = RemoveScheme(s) 34 | } 35 | return s 36 | } 37 | 38 | // Domain returns the domain name of the URL. If includeSub is true and there is a subdomain, it includes the subdomain 39 | // in the returned string. Otherwise, it only includes the domain. 40 | func (url URL) Domain(includeSub bool) string { 41 | if includeSub && url.SubName != "" { 42 | return fmt.Sprintf("%s.%s.%s", url.SubName, url.Name, url.TLD) 43 | } 44 | return fmt.Sprintf("%s.%s", url.Name, url.TLD) 45 | } 46 | 47 | // NoWWW returns the domain name without the "www" subdomain. 48 | // If the subdomain is not "www" or is empty, it returns the domain name as is. 49 | // The returned domain name is a string in the format "subname.name.tld". 50 | func (url URL) NoWWW() string { 51 | if url.SubName != "www" && url.SubName != "" { 52 | return fmt.Sprintf("%s.%s.%s", url.SubName, url.Name, url.TLD) 53 | } 54 | return fmt.Sprintf("%s.%s", url.Name, url.TLD) 55 | } 56 | 57 | // WWW returns the domain name with the "www" subdomain. 58 | // If the subdomain is not "www", it returns the domain name as is. 59 | // The returned domain name is a string in the format "subname.name.tld". 60 | func (url URL) WWW() string { 61 | if url.SubName != "" { 62 | return fmt.Sprintf("%s.%s.%s", url.SubName, url.Name, url.TLD) 63 | } 64 | return fmt.Sprintf("%s.%s.%s", "www", url.Name, url.TLD) 65 | } 66 | 67 | // HTTPS returns the URL with HTTPS Scheme but leaves the URL itself untouched. 68 | func (url URL) HTTPS() string { 69 | rememberScheme := url.Scheme 70 | url.Scheme = "https" 71 | httpsURL := url.String(true) 72 | url.Scheme = rememberScheme 73 | return httpsURL 74 | } 75 | 76 | // StripWWW returns the URL without "www" subdomain, but leaves the URL itself untouched. 77 | // This function returns the whole URL with its path, in contrast to NoWWW(). 78 | func (url URL) StripWWW(includeScheme bool) string { 79 | if url.SubName == "www" { 80 | return strings.Replace(url.String(includeScheme), "www.", "", 1) 81 | } 82 | return url.String(includeScheme) 83 | } 84 | 85 | // StripQueryParams removes query parameters and fragments from the URL and returns 86 | // the URL as a string. If includeScheme is true, it includes the scheme in the returned URL. 87 | func (url URL) StripQueryParams(includeScheme bool) string { 88 | // Remember the original values of query parameters and fragments 89 | rememberRawQuery := url.RawQuery 90 | rememberFragment := url.Fragment 91 | rememberRawFragment := url.RawFragment 92 | 93 | // Clear the query parameters and fragments 94 | url.RawQuery = "" 95 | url.RawFragment = "" 96 | url.Fragment = "" 97 | 98 | // Get the URL without query parameters 99 | urlWithoutQuery := url.String(includeScheme) 100 | 101 | // Restore the original values of query parameters and fragments 102 | url.RawQuery = rememberRawQuery 103 | url.RawFragment = rememberRawFragment 104 | url.Fragment = rememberFragment 105 | 106 | return urlWithoutQuery 107 | } 108 | 109 | // IsLocal checks if the URL is a local address. 110 | // It returns true if the URL's top-level domain (TLD) is "localhost" or if the URL's 111 | // hostname resolves to a loopback IP address. 112 | func (url URL) IsLocal() bool { 113 | ip := net.ParseIP(strings.TrimPrefix(strings.TrimSuffix(url.Name, ipV6URINotationSuffix), ipV6URINotationPrefix)) 114 | return url.TLD == "localhost" || (ip != nil && ip.IsLoopback()) 115 | } 116 | 117 | // Parse parses a string representation of a URL and returns a *URL and error. 118 | // It mirrors the net/url.Parse function but returns a tld.URL, which contains extra fields. 119 | func Parse(urlString string) (*URL, error) { 120 | urlString = strings.TrimSpace(urlString) 121 | 122 | // if the url to be parsed is empty after trimming, we return an error 123 | if len(urlString) == 0 { 124 | return nil, ErrEmptyURL 125 | } 126 | 127 | urlString = AddDefaultScheme(urlString) 128 | parsedURL, err := url.Parse(urlString) 129 | if err != nil { 130 | return nil, fmt.Errorf("could not parse url: %w", err) 131 | } 132 | // always lowercase subdomain.domain.tld (host property) 133 | parsedURL.Host = strings.ToLower(parsedURL.Host) 134 | if parsedURL.Host == "" { 135 | return &URL{URL: parsedURL}, nil 136 | } 137 | dom, port := domainPort(parsedURL.Host) 138 | var domName, tld, sub string 139 | ip := net.ParseIP(strings.TrimPrefix(strings.TrimSuffix(dom, ipV6URINotationSuffix), ipV6URINotationPrefix)) 140 | switch { 141 | case ip != nil: 142 | domName = dom 143 | case dom == "localhost": 144 | tld = dom 145 | default: 146 | etld1, err := publicsuffix.EffectiveTLDPlusOne(dom) 147 | if err != nil { 148 | return nil, fmt.Errorf("failed to extract eTLD+1: %w", err) 149 | } 150 | i := strings.Index(etld1, ".") 151 | domName = etld1[0:i] 152 | tld = etld1[i+1:] 153 | sub = "" 154 | if rest := strings.TrimSuffix(dom, "."+etld1); rest != dom { 155 | sub = rest 156 | } 157 | } 158 | urlString, err = idna.ToASCII(dom) 159 | if err != nil { 160 | return nil, fmt.Errorf("failed to convert domain to ASCII: %w", err) 161 | } 162 | return &URL{ 163 | SubName: sub, 164 | Name: domName, 165 | TLD: tld, 166 | Port: port, 167 | URL: parsedURL, 168 | IsDomain: IsDomainName(urlString), 169 | }, nil 170 | } 171 | 172 | // FromParsed mirrors the net/url.Parse function, 173 | // but instead of returning a *url.URL, it returns a *URL, 174 | // which is a struct that contains additional fields. 175 | // 176 | // The function first checks if the parsedUrl.Host field is empty. 177 | // If it is empty, it returns a *URL with the URL field set to parsedUrl 178 | // and all other fields set to their zero values. 179 | // 180 | // If the parsedUrl.Host field is not empty, it extracts the domain and port 181 | // using the domainPort function. 182 | // 183 | // It then calculates the effective top-level domain plus one (etld+1) 184 | // using the publicsuffix.EffectiveTLDPlusOne function. 185 | // 186 | // The etld+1 is then split into the domain name (domName) and the top-level domain (tld). 187 | // 188 | // It further determines the subdomain (sub) by checking if the domain is a subdomain of the etld+1. 189 | // 190 | // The domain name (domName) is then converted to ASCII using the idna.ToASCII function. 191 | // 192 | // Finally, it returns a *URL with the extracted values and the URL field set to parsedUrl. 193 | // The IsDomain field is set to the result of the IsDomainName function called with the ASCII domain name. 194 | // The SubName field is set to sub, the Name field is set to domName, and the T. 195 | func FromParsed(parsedURL *url.URL) (*URL, error) { 196 | if parsedURL.Host == "" { 197 | return &URL{URL: parsedURL}, nil 198 | } 199 | dom, port := domainPort(parsedURL.Host) 200 | // etld+1 201 | etld1, err := publicsuffix.EffectiveTLDPlusOne(dom) 202 | if err != nil { 203 | return nil, fmt.Errorf("failed to extract eTLD+1: %w", err) 204 | } 205 | // convert to domain name, and tld 206 | i := strings.Index(etld1, ".") 207 | domName := etld1[0:i] 208 | tld := etld1[i+1:] 209 | // and subdomain 210 | sub := "" 211 | if rest := strings.TrimSuffix(dom, "."+etld1); rest != dom { 212 | sub = rest 213 | } 214 | asciiDom, err := idna.ToASCII(dom) 215 | if err != nil { 216 | return nil, fmt.Errorf("failed to convert domain to ASCII: %w", err) 217 | } 218 | return &URL{ 219 | SubName: sub, 220 | Name: domName, 221 | TLD: tld, 222 | Port: port, 223 | URL: parsedURL, 224 | IsDomain: IsDomainName(asciiDom), 225 | }, nil 226 | } 227 | 228 | // domainPort extracts the domain and port from the host part of a URL. 229 | // If the host contains a port, it returns the domain without the port and the port as strings. 230 | // If the host does not contain a port, it returns the domain and an empty string for the port. 231 | // If the host is all numeric characters, it returns the host itself and an empty string for the port. 232 | // Note that the net/url package should prevent the string from being all numeric characters. 233 | func domainPort(host string) (string, string) { 234 | for i := len(host) - 1; i >= 0; i-- { 235 | if host[i] == ':' { 236 | return host[:i], host[i+1:] 237 | } else if host[i] < '0' || host[i] > '9' { 238 | return host, "" 239 | } 240 | } 241 | // will only land here if the string is all digits, 242 | // net/url should prevent that from happening 243 | return host, "" 244 | } 245 | 246 | // IsDomainName checks if a string represents a valid domain name. 247 | // 248 | // It follows the rules specified in RFC 1035 and RFC 3696 for domain name validation. 249 | // 250 | // The input string is first processed with the RemoveScheme function to remove any scheme prefix. 251 | // The domain name is then split into labels using the dot separator. 252 | // The function checks that the number of labels is at least 2 and that the total length of the string is between 1 and 253 | // 254 characters. 254 | // 255 | // The function iterates over the characters of the string and performs checks based on the character type. 256 | // Valid characters include letters (a-zA-Z), digits (0-9), underscore (_), and hyphen (-). 257 | // Each label can contain up to 63 characters and the last label cannot end with a hyphen. 258 | // The function also checks that the byte before a dot or a hyphen is not a dot or a hyphen, respectively. 259 | // Non-numeric characters are tracked to ensure the presence of at least one non-numeric character in the domain name. 260 | // 261 | // If any of the checks fail, the function returns false. Otherwise, it returns true. 262 | // 263 | // Example usage: 264 | // s := "mail.google.com" 265 | // isValid := IsDomainName(s). 266 | func IsDomainName(name string) bool { //nolint:cyclop 267 | name = RemoveScheme(name) 268 | // See RFC 1035, RFC 3696. 269 | // Presentation format has dots before every label except the first, and the 270 | // terminal empty label is optional here because we assume fully-qualified 271 | // (absolute) input. We must therefore reserve space for the first and last 272 | // labels' length octets in wire format, where they are necessary and the 273 | // maximum total length is 255. 274 | // So our _effective_ maximum is 253, but 254 is not rejected if the last 275 | // character is a dot. 276 | split := strings.Split(name, ".") 277 | 278 | // Need a TLD and a domain. 279 | if len(split) < 2 { //nolint:mnd 280 | return false 281 | } 282 | l := len(name) 283 | if l == 0 || l > 254 || l == 254 && name[l-1] != '.' { 284 | return false 285 | } 286 | 287 | last := byte('.') 288 | nonNumeric := false // true once we've seen a letter or hyphen 289 | partlen := 0 290 | for i := 0; i < len(name); i++ { 291 | char := name[i] 292 | switch { 293 | default: 294 | return false 295 | case 'a' <= char && char <= 'z' || 'A' <= char && char <= 'Z' || char == '_': 296 | nonNumeric = true 297 | partlen++ 298 | case '0' <= char && char <= '9': 299 | // fine 300 | partlen++ 301 | case char == '-': 302 | // Byte before dash cannot be dot. 303 | if last == '.' { 304 | return false 305 | } 306 | partlen++ 307 | nonNumeric = true 308 | case char == '.': 309 | // Byte before dot cannot be dot, dash. 310 | if last == '.' || last == '-' { 311 | return false 312 | } 313 | if partlen > 63 || partlen == 0 { 314 | return false 315 | } 316 | partlen = 0 317 | } 318 | last = char 319 | } 320 | if last == '-' || partlen > 63 { 321 | return false 322 | } 323 | 324 | return nonNumeric 325 | } 326 | 327 | // RemoveScheme removes the scheme from a URL string. 328 | // If the URL string includes a scheme (e.g., "http://"), 329 | // the scheme will be removed and the remaining string will be returned. 330 | // If the URL string includes a default scheme (e.g., "//"), 331 | // the default scheme will be removed and the remaining string will be returned. 332 | // If the URL string does not include a scheme, the original string will be returned unchanged. 333 | func RemoveScheme(s string) string { 334 | if strings.Contains(s, "://") { 335 | return removeScheme(s) 336 | } 337 | if strings.Contains(s, "//") { 338 | return removeDefaultScheme(s) 339 | } 340 | return s 341 | } 342 | 343 | // add default scheme if string does not include a scheme. 344 | func AddDefaultScheme(s string) string { 345 | if !strings.Contains(s, "//") || 346 | (!strings.Contains(s, "//") && !strings.Contains(s, ":") && !strings.Contains(s, "@")) { 347 | return addDefaultScheme(s) 348 | } 349 | return s 350 | } 351 | 352 | func AddScheme(s, scheme string) string { 353 | if scheme == "" { 354 | return AddDefaultScheme(s) 355 | } 356 | if strings.Index(s, "//") == -1 { 357 | return fmt.Sprintf("%s://%s", scheme, s) 358 | } 359 | return s 360 | } 361 | 362 | // addDefaultScheme returns a new string with a default scheme added. 363 | // The default scheme format is "//". 364 | func addDefaultScheme(s string) string { 365 | return fmt.Sprintf("//%s", s) 366 | } 367 | 368 | // removeDefaultScheme removes the default scheme from a string. 369 | func removeDefaultScheme(s string) string { 370 | return s[index(s, "//"):] 371 | } 372 | 373 | func removeScheme(s string) string { 374 | return s[index(s, "://"):] 375 | } 376 | 377 | // index returns the starting index of the first occurrence of the specified scheme in the given string. 378 | // If the scheme is not found, it returns -1. 379 | // The returned int is incremented by the length of the scheme to obtain the starting position of the remaining string. 380 | func index(s, scheme string) int { 381 | return strings.Index(s, scheme) + len(scheme) 382 | } 383 | -------------------------------------------------------------------------------- /exit/exit.go: -------------------------------------------------------------------------------- 1 | package exit 2 | 3 | import ( 4 | "encoding/base32" 5 | "encoding/hex" 6 | "fmt" 7 | "log/slog" 8 | "net" 9 | "strings" 10 | 11 | "github.com/asmogo/nws/config" 12 | "github.com/asmogo/nws/netstr" 13 | "github.com/asmogo/nws/protocol" 14 | "github.com/asmogo/nws/socks5" 15 | "github.com/btcsuite/btcd/btcec/v2" 16 | "github.com/btcsuite/btcd/btcec/v2/schnorr" 17 | "github.com/ekzyis/nip44" 18 | "github.com/nbd-wtf/go-nostr" 19 | "github.com/nbd-wtf/go-nostr/nip19" 20 | "github.com/puzpuzpuz/xsync/v3" 21 | "golang.org/x/net/context" 22 | ) 23 | 24 | const ( 25 | startingReverseProxyMessage = "starting exit node with https reverse proxy" 26 | generateKeyMessage = "Generated new private key. Please set your environment using the new key, otherwise your key will be lost." //nolint: lll 27 | ) 28 | 29 | // Exit represents a structure that holds information related to an exit node. 30 | type Exit struct { 31 | // pool represents a pool of relays and manages the subscription to incoming events from relays. 32 | pool *nostr.SimplePool 33 | // config is a field in the Exit struct that holds information related to exit node configuration. 34 | config *config.ExitConfig 35 | // relays represents a slice of *nostr.Relay, which contains information about the relay nodes used by the Exit node. 36 | // Todo -- check if this is deprecated 37 | relays []*nostr.Relay 38 | // nostrConnectionMap is a concurrent map used to store connections for the Exit node. 39 | // It is used to establish and maintain connections between the Exit node and the backend host. 40 | nostrConnectionMap *xsync.MapOf[string, *netstr.NostrConnection] 41 | // mutexMap is a field in the Exit struct used for synchronizing access to resources based on a string key. 42 | mutexMap *MutexMap 43 | // incomingChannel represents a channel used to receive incoming events from relays. 44 | incomingChannel chan nostr.IncomingEvent 45 | nprofile string 46 | publicKey string 47 | } 48 | 49 | func New(ctx context.Context, exitNodeConfig *config.ExitConfig) *Exit { 50 | // Generate new private key if needed 51 | generatePrivateKeyIfNeeded(exitNodeConfig) 52 | 53 | // Create a new exit node 54 | exit, err := createExitNode(ctx, exitNodeConfig) 55 | if err != nil { 56 | panic(err) 57 | } 58 | 59 | // Setup reverse proxy if HTTPS port is set 60 | setupReverseProxy(ctx, exit, exitNodeConfig) 61 | 62 | // Add relays to the pool 63 | addRelaysToPool(exit, exitNodeConfig.NostrRelays) 64 | 65 | if err := exit.setSubscriptions(ctx); err != nil { 66 | panic(err) 67 | } 68 | 69 | if err := exit.announceExitNode(ctx); err != nil { 70 | slog.Error("failed to announce exit node", "error", err) 71 | } 72 | 73 | printExitNodeInfo(exit, exitNodeConfig) 74 | 75 | return exit 76 | } 77 | 78 | func printExitNodeInfo(exit *Exit, exitNodeConfig *config.ExitConfig) { 79 | // Set up remaining steps for the exit node 80 | domain, err := exit.getDomain() 81 | if err != nil { 82 | panic(err) 83 | } 84 | slog.Info("created exit node", "profile", exitNodeConfig.NostrRelays, "domain", domain) 85 | } 86 | 87 | func newExit(pool *nostr.SimplePool, pubKey string, profile string) *Exit { 88 | exit := &Exit{ 89 | nostrConnectionMap: xsync.NewMapOf[string, *netstr.NostrConnection](), 90 | pool: pool, 91 | mutexMap: NewMutexMap(), 92 | publicKey: pubKey, 93 | nprofile: profile, 94 | } 95 | return exit 96 | } 97 | 98 | func generatePrivateKeyIfNeeded(cfg *config.ExitConfig) { 99 | if cfg.NostrPrivateKey == "" { 100 | cfg.NostrPrivateKey = nostr.GeneratePrivateKey() 101 | slog.Warn(generateKeyMessage, "key", cfg.NostrPrivateKey) 102 | } 103 | } 104 | 105 | func createExitNode(ctx context.Context, cfg *config.ExitConfig) (*Exit, error) { 106 | pubKey, err := nostr.GetPublicKey(cfg.NostrPrivateKey) 107 | if err != nil { 108 | return nil, fmt.Errorf("failed to get public key: %w", err) 109 | } 110 | slog.Info("using public key", "key", pubKey) 111 | 112 | profile, err := nip19.EncodeProfile(pubKey, cfg.NostrRelays) 113 | if err != nil { 114 | return nil, fmt.Errorf("failed to encode profile: %w", err) 115 | } 116 | 117 | pool := nostr.NewSimplePool(ctx) 118 | exit := newExit(pool, pubKey, profile) 119 | exit.config = cfg 120 | 121 | return exit, nil 122 | } 123 | 124 | func setupReverseProxy(ctx context.Context, exit *Exit, cfg *config.ExitConfig) { 125 | if cfg.HttpsPort != 0 { 126 | cfg.BackendHost = fmt.Sprintf(":%d", cfg.HttpsPort) 127 | go func(ctx context.Context, cfg *config.ExitConfig) { 128 | slog.Info(startingReverseProxyMessage, "port", cfg.HttpsPort) 129 | err := exit.StartReverseProxy(ctx, cfg.HttpsTarget, cfg.HttpsPort) 130 | if err != nil { 131 | panic(err) 132 | } 133 | }(ctx, cfg) 134 | } 135 | } 136 | 137 | func addRelaysToPool(exit *Exit, relays []string) { 138 | for _, relayURL := range relays { 139 | relay, err := exit.pool.EnsureRelay(relayURL) 140 | if err != nil { 141 | slog.Error("failed to ensure relay", "url", relayURL, "error", err) 142 | continue 143 | } 144 | exit.relays = append(exit.relays, relay) 145 | slog.Info("added relay connection", "url", relayURL) 146 | } 147 | } 148 | 149 | // getDomain returns the domain string used by the Exit node for communication with the Nostr relays. 150 | // It concatenates the relay URLs using base32 encoding with no padding, separated by dots. 151 | // The domain is then appended with the base32 encoded public key obtained using the configured Nostr private key. 152 | // The final domain string is converted to lowercase and returned. 153 | func (e *Exit) getDomain() (string, error) { 154 | var domain string 155 | // first lets build the subdomains 156 | for _, relayURL := range e.config.NostrRelays { 157 | if domain == "" { 158 | domain = base32.HexEncoding.WithPadding(base32.NoPadding).EncodeToString([]byte(relayURL)) 159 | } else { 160 | domain = fmt.Sprintf("%s.%s", 161 | domain, base32.HexEncoding.WithPadding(base32.NoPadding).EncodeToString([]byte(relayURL))) 162 | } 163 | } 164 | // create base32 encoded public key 165 | decoded, err := GetPublicKeyBase32(e.config.NostrPrivateKey) 166 | if err != nil { 167 | return "", err 168 | } 169 | // use public key as host. add TLD 170 | domain = strings.ToLower(fmt.Sprintf("%s.%s.nostr", domain, decoded)) 171 | return domain, nil 172 | } 173 | 174 | // GetPublicKeyBase32 decodes the private key string from hexadecimal format 175 | // and returns the base32 encoded public key obtained using the provided private key. 176 | // The base32 encoding has no padding. If there is an error decoding the private key 177 | // or generating the public key, an error is returned. 178 | func GetPublicKeyBase32(sk string) (string, error) { 179 | b, err := hex.DecodeString(sk) 180 | if err != nil { 181 | return "", fmt.Errorf("failed to decode private key: %w", err) 182 | } 183 | _, pk := btcec.PrivKeyFromBytes(b) 184 | return base32.HexEncoding.WithPadding(base32.NoPadding).EncodeToString(schnorr.SerializePubKey(pk)), nil 185 | } 186 | 187 | // setSubscriptions sets up subscriptions for the Exit node to receive incoming events from the specified relays. 188 | // It first obtains the public key using the configured Nostr private key. 189 | // Then it calls the `handleSubscription` method to open a subscription to the relays with the specified filters. 190 | // This method runs in a separate goroutine and continuously handles the incoming events by calling `processMessage` 191 | // If the context is canceled before the subscription is established, it returns the context error. 192 | // If any errors occur during the process, they are returned. 193 | // This method should be called once when starting the Exit node. 194 | func (e *Exit) setSubscriptions(ctx context.Context) error { 195 | pubKey, err := nostr.GetPublicKey(e.config.NostrPrivateKey) 196 | if err != nil { 197 | return fmt.Errorf("failed to get public key: %w", err) 198 | } 199 | if err = e.handleSubscription(ctx, pubKey, nostr.Now()); err != nil { 200 | return fmt.Errorf("failed to handle subscription: %w", err) 201 | } 202 | return nil 203 | } 204 | 205 | // handleSubscription handles the subscription to incoming events from relays based on the provided filters. 206 | // It sets up the incoming event channel and starts a goroutine to handle the events. 207 | // It returns an error if there is any issue with the subscription. 208 | func (e *Exit) handleSubscription(ctx context.Context, pubKey string, since nostr.Timestamp) error { 209 | incomingEventChannel := e.pool.SubMany(ctx, e.config.NostrRelays, nostr.Filters{ 210 | { 211 | Kinds: []int{protocol.KindEphemeralEvent}, 212 | Since: &since, 213 | Tags: nostr.TagMap{ 214 | "p": []string{pubKey}, 215 | }, 216 | }, 217 | }) 218 | e.incomingChannel = incomingEventChannel 219 | return nil 220 | } 221 | 222 | // ListenAndServe handles incoming events from the subscription channel. 223 | // It processes each event by calling the processMessage method, as long as the event is not nil. 224 | // If the context is canceled (ctx.Done() receives a value), the method returns. 225 | func (e *Exit) ListenAndServe(ctx context.Context) { 226 | for { 227 | select { 228 | case event := <-e.incomingChannel: 229 | slog.Debug("received event", "event", event) 230 | if event.Relay == nil { 231 | continue 232 | } 233 | go e.processMessage(ctx, event) 234 | case <-ctx.Done(): 235 | return 236 | } 237 | } 238 | } 239 | 240 | // processMessage decrypts and unmarshals the incoming event message, and then 241 | // routes the message to the appropriate handler based on its protocol type. 242 | func (e *Exit) processMessage(ctx context.Context, msg nostr.IncomingEvent) { 243 | // hex decode the target public key 244 | privateKeyBytes, targetPublicKeyBytes, err := protocol.GetEncryptionKeys(e.config.NostrPrivateKey, msg.PubKey) 245 | if err != nil { 246 | slog.Error("could not get encryption keys", "error", err) 247 | return 248 | } 249 | sharedKey, err := nip44.GenerateConversationKey(privateKeyBytes, targetPublicKeyBytes) 250 | if err != nil { 251 | slog.Error("could not compute shared key", "error", err) 252 | return 253 | } 254 | decodedMessage, err := nip44.Decrypt(sharedKey, msg.Content) 255 | if err != nil { 256 | slog.Error("could not decrypt message", "error", err) 257 | return 258 | } 259 | protocolMessage, err := protocol.UnmarshalJSON([]byte(decodedMessage)) 260 | if err != nil { 261 | slog.Error("could not unmarshal message", "error", err) 262 | return 263 | } 264 | destination, err := protocol.Parse(protocolMessage.Destination) 265 | if err != nil { 266 | slog.Error("could not parse destination", "error", err) 267 | return 268 | } 269 | if destination.TLD == "nostr" { 270 | protocolMessage.Destination = e.config.BackendHost 271 | } 272 | switch protocolMessage.Type { 273 | case protocol.MessageConnect: 274 | e.handleConnect(ctx, msg, protocolMessage) 275 | case protocol.MessageConnectReverse: 276 | e.handleConnectReverse(protocolMessage) 277 | case protocol.MessageTypeSocks5: 278 | e.handleSocks5ProxyMessage(msg, protocolMessage) 279 | } 280 | } 281 | 282 | // handleConnect handles the connection for the given message and protocol message. 283 | // It locks the mutex for the protocol message key, encodes the receiver's profile, 284 | // creates a new connection with the provided context and options, and establishes 285 | // a connection to the backend host. 286 | // If the connection cannot be established, it logs an error and returns. 287 | // It then stores the connection in the nostrConnectionMap and creates two goroutines 288 | // to proxy the data between the connection and the backend. 289 | func (e *Exit) handleConnect( 290 | ctx context.Context, 291 | msg nostr.IncomingEvent, 292 | protocolMessage *protocol.Message, 293 | ) { 294 | e.mutexMap.Lock(protocolMessage.Key.String()) 295 | defer e.mutexMap.Unlock(protocolMessage.Key.String()) 296 | receiver, err := nip19.EncodeProfile(msg.PubKey, []string{msg.Relay.String()}) 297 | if err != nil { 298 | return 299 | } 300 | connection := netstr.NewConnection( 301 | ctx, 302 | netstr.WithPrivateKey(e.config.NostrPrivateKey), 303 | netstr.WithDst(receiver), 304 | netstr.WithUUID(protocolMessage.Key), 305 | ) 306 | 307 | var dst net.Conn 308 | dst, err = net.Dial("tcp", protocolMessage.Destination) 309 | if err != nil { 310 | slog.Error("could not connect to backend", "error", err) 311 | err = connection.Close() 312 | if err != nil { 313 | slog.Error("could not close connection", "error", err) 314 | } 315 | return 316 | } 317 | 318 | e.nostrConnectionMap.Store(protocolMessage.Key.String(), connection) 319 | slog.Info("connected to backend", "key", protocolMessage.Key) 320 | go socks5.Proxy(dst, connection, nil) 321 | go socks5.Proxy(connection, dst, nil) 322 | } 323 | 324 | func (e *Exit) handleConnectReverse(protocolMessage *protocol.Message) { 325 | e.mutexMap.Lock(protocolMessage.Key.String()) 326 | defer e.mutexMap.Unlock(protocolMessage.Key.String()) 327 | connection, err := net.Dial("tcp", protocolMessage.EntryPublicAddress) 328 | if err != nil { 329 | slog.Error("could not connect to entry", "error", err) 330 | return 331 | } 332 | 333 | _, err = connection.Write([]byte(protocolMessage.Key.String())) 334 | if err != nil { 335 | return 336 | } 337 | // read single byte from the connection 338 | readbuffer := make([]byte, 1) 339 | _, err = connection.Read(readbuffer) 340 | if err != nil { 341 | slog.Error("could not read from connection", "error", err) 342 | return 343 | } 344 | if readbuffer[0] != 1 { 345 | return 346 | } 347 | var dst net.Conn 348 | dst, err = net.Dial("tcp", protocolMessage.Destination) 349 | if err != nil { 350 | slog.Error("could not connect to backend", "error", err) 351 | connection.Close() 352 | return 353 | } 354 | slog.Info("connected to entry", "key", protocolMessage.Key) 355 | go socks5.Proxy(dst, connection, nil) 356 | go socks5.Proxy(connection, dst, nil) 357 | } 358 | 359 | // handleSocks5ProxyMessage handles the SOCKS5 proxy message by writing it to the destination connection. 360 | // If the destination connection does not exist, the function returns without doing anything. 361 | // 362 | // Parameters: 363 | // - msg: The incoming event containing the SOCKS5 proxy message. 364 | // - protocolMessage: The protocol message associated with the incoming event. 365 | func (e *Exit) handleSocks5ProxyMessage( 366 | msg nostr.IncomingEvent, 367 | protocolMessage *protocol.Message, 368 | ) { 369 | e.mutexMap.Lock(protocolMessage.Key.String()) 370 | defer e.mutexMap.Unlock(protocolMessage.Key.String()) 371 | dst, ok := e.nostrConnectionMap.Load(protocolMessage.Key.String()) 372 | if !ok { 373 | return 374 | } 375 | dst.WriteNostrEvent(msg) 376 | slog.Info("wrote event to backend", "key", protocolMessage.Key) 377 | } 378 | -------------------------------------------------------------------------------- /netstr/conn.go: -------------------------------------------------------------------------------- 1 | package netstr 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/base32" 7 | "encoding/base64" 8 | "encoding/hex" 9 | "errors" 10 | "fmt" 11 | "log/slog" 12 | "net" 13 | "strings" 14 | "time" 15 | 16 | "github.com/asmogo/nws/protocol" 17 | "github.com/btcsuite/btcd/btcec/v2/schnorr" 18 | "github.com/ekzyis/nip44" 19 | "github.com/google/uuid" 20 | "github.com/nbd-wtf/go-nostr" 21 | "github.com/nbd-wtf/go-nostr/nip19" 22 | "github.com/samber/lo" 23 | ) 24 | 25 | // NostrConnection implements the net.Conn interface. 26 | // It is used to establish a connection to Nostr relays. 27 | // It provides methods for reading and writing data. 28 | type NostrConnection struct { 29 | // uuid is a field of type uuid.UUID in the NostrConnection struct. 30 | uuid uuid.UUID 31 | // ctx is a field of type context.Context in the NostrConnection struct. 32 | ctx context.Context 33 | // cancel is a field of type context.CancelFunc in the NostrConnection struct. 34 | cancel context.CancelFunc 35 | 36 | // readBuffer is a field of type `bytes.Buffer` in the `NostrConnection` struct. 37 | // It is used to store the decrypted message from incoming events. 38 | // The `handleSubscription` method continuously listens for events on the subscription channel, 39 | // decrypts the event content, and writes the decrypted message to the `readBuffer`. 40 | readBuffer bytes.Buffer 41 | 42 | // private key of the connection 43 | privateKey string 44 | 45 | // NostrConnection represents a connection object. 46 | pool *nostr.SimplePool 47 | 48 | // dst is a field that represents the destination address for the Nostr connection configuration. 49 | dst string 50 | 51 | // subscriptionChan is a channel of type protocol.IncomingEvent. 52 | // It is used to write incoming events which will be read and processed by the Read method. 53 | subscriptionChan chan nostr.IncomingEvent 54 | 55 | // readIDs represents the list of event IDs that have been read by the NostrConnection object. 56 | readIDs []string 57 | 58 | // writeIDs is a field of type []string in the NostrConnection struct. 59 | // It stores the IDs of the events that have been written to the connection. 60 | // This field is used to check if an event has already been written and avoid duplicate writes. 61 | writeIDs []string 62 | 63 | // sentBytes is a field that stores the bytes of data that have been sent by the connection. 64 | sentBytes [][]byte 65 | 66 | // sub represents a boolean value indicating if a connection should subscribe to a response when writing. 67 | sub bool 68 | defaultRelays []string 69 | targetPublicKey string 70 | } 71 | 72 | var errContextCanceled = errors.New("context canceled") 73 | 74 | // WriteNostrEvent writes the incoming event to the subscription channel of the NostrConnection. 75 | // The subscription channel is used by the Read method to read events and handle them. 76 | // Parameters: 77 | // - event: The incoming event to be written to the subscription channel. 78 | func (nc *NostrConnection) WriteNostrEvent(event nostr.IncomingEvent) { 79 | nc.subscriptionChan <- event 80 | } 81 | 82 | // NewConnection creates a new NostrConnection object with the provided context and options. 83 | // It initializes the config with default values, processes the options to customize the config, 84 | // and creates a new NostrConnection object using the config. 85 | // If an uuid is provided in the options, it is assigned to the NostrConnection object. 86 | // The NostrConnection object is then returned. 87 | func NewConnection(ctx context.Context, opts ...NostrConnOption) *NostrConnection { 88 | ctx, c := context.WithCancel(ctx) 89 | nostrConnection := &NostrConnection{ 90 | pool: nostr.NewSimplePool(ctx), 91 | ctx: ctx, 92 | cancel: c, 93 | subscriptionChan: make(chan nostr.IncomingEvent), 94 | readIDs: make([]string, 0), 95 | sentBytes: make([][]byte, 0), 96 | } 97 | for _, opt := range opts { 98 | opt(nostrConnection) 99 | } 100 | 101 | return nostrConnection 102 | } 103 | 104 | // Read reads data from the connection. The data is decrypted and returned in the provided byte slice. 105 | // If there is no data available, Read blocks until data arrives or the context is canceled. 106 | // If the context is canceled before data is received, Read returns an error. 107 | // 108 | // The number of bytes read is returned as n and any error encountered is returned as err. 109 | // The content of the decrypted message is then copied to the provided byte slice b. 110 | func (nc *NostrConnection) Read(b []byte) (int, error) { 111 | return nc.handleNostrRead(b) 112 | } 113 | 114 | // handleNostrRead reads the incoming events from the subscription channel and processes them. 115 | // It checks if the event has already been read, decrypts the content using the shared key, 116 | // unmarshals the decoded message and copies the content into the provided byte slice. 117 | // It returns the number of bytes copied and any error encountered. 118 | // If the context is canceled, it returns an error with "context canceled" message. 119 | func (nc *NostrConnection) handleNostrRead(buffer []byte) (int, error) { 120 | for { 121 | select { 122 | case event := <-nc.subscriptionChan: 123 | if event.Relay == nil { 124 | return 0, nil 125 | } 126 | // check if we have already read this event 127 | if lo.Contains(nc.readIDs, event.ID) { 128 | continue 129 | } 130 | nc.readIDs = append(nc.readIDs, event.ID) 131 | // hex decode the target public key 132 | privateKeyBytes, targetPublicKeyBytes, err := protocol.GetEncryptionKeys(nc.privateKey, event.PubKey) 133 | if err != nil { 134 | return 0, fmt.Errorf("could not get encryption keys: %w", err) 135 | } 136 | sharedKey, err := nip44.GenerateConversationKey(privateKeyBytes, targetPublicKeyBytes) 137 | if err != nil { 138 | return 0, fmt.Errorf("could not compute shared key: %w", err) 139 | } 140 | decodedMessage, err := nip44.Decrypt(sharedKey, event.Content) 141 | if err != nil { 142 | return 0, fmt.Errorf("could not decrypt message: %w", err) 143 | } 144 | message, err := protocol.UnmarshalJSON([]byte(decodedMessage)) 145 | if err != nil { 146 | return 0, fmt.Errorf("could not unmarshal message: %w", err) 147 | } 148 | slog.Debug("reading", 149 | slog.String("event", event.ID), 150 | slog.String("content", base64.StdEncoding.EncodeToString(message.Data)), 151 | ) 152 | n := copy(buffer, message.Data) 153 | return n, nil 154 | case <-nc.ctx.Done(): 155 | return 0, errContextCanceled 156 | default: 157 | time.Sleep(time.Millisecond * 100) 158 | } 159 | } 160 | } 161 | 162 | // Write writes data to the connection. 163 | // It delegates the writing logic to handleNostrWrite method. 164 | // The number of bytes written and error (if any) are returned. 165 | func (nc *NostrConnection) Write(b []byte) (int, error) { 166 | return nc.handleNostrWrite(b) 167 | } 168 | 169 | // Go lang 170 | func (nc *NostrConnection) handleNostrWrite(buffer []byte) (int, error) { 171 | if nc.ctx.Err() != nil { 172 | return 0, fmt.Errorf("context canceled: %w", nc.ctx.Err()) 173 | } 174 | publicKey, relays, err := nc.parseDestination() 175 | if err != nil { 176 | return 0, fmt.Errorf("could not parse destination: %w", err) 177 | } 178 | signer, err := protocol.NewEventSigner(nc.privateKey) 179 | if err != nil { 180 | return 0, fmt.Errorf("could not create event signer: %w", err) 181 | } 182 | signedEvent, err := nc.createSignedEvent(signer, buffer, publicKey, relays) 183 | if err != nil { 184 | return 0, fmt.Errorf("could not create signed event: %w", err) 185 | } 186 | err = nc.publishEventToRelays(signedEvent, relays) 187 | if err != nil { 188 | return 0, fmt.Errorf("could not publish event to relays: %w", err) 189 | } 190 | nc.appendSentBytes(buffer) 191 | slog.Debug("writing", 192 | slog.String("event", signedEvent.ID), 193 | slog.String("content", base64.StdEncoding.EncodeToString(buffer)), 194 | ) 195 | return len(buffer), nil 196 | } 197 | 198 | func (nc *NostrConnection) createSignedEvent( 199 | signer *protocol.EventSigner, 200 | b []byte, 201 | publicKey string, 202 | relays []string, 203 | ) (nostr.Event, error) { 204 | opts := []protocol.MessageOption{ 205 | protocol.WithUUID(nc.uuid), 206 | protocol.WithType(protocol.MessageTypeSocks5), 207 | protocol.WithDestination(nc.dst), 208 | protocol.WithData(b), 209 | } 210 | signedEvent, err := signer.CreateSignedEvent( 211 | publicKey, 212 | protocol.KindEphemeralEvent, 213 | nostr.Tags{nostr.Tag{"p", publicKey}}, 214 | opts..., 215 | ) 216 | if err != nil { 217 | return signedEvent, fmt.Errorf("could not create signed event: %w", err) 218 | } 219 | if lo.Contains(nc.writeIDs, signedEvent.ID) { 220 | slog.Info("event already sent", slog.String("event", signedEvent.ID)) 221 | return signedEvent, nil 222 | } 223 | nc.writeIDs = append(nc.writeIDs, signedEvent.ID) 224 | if nc.sub { 225 | nc.sub = false 226 | now := nostr.Now() 227 | incomingEventChannel := nc.pool.SubMany(nc.ctx, relays, 228 | nostr.Filters{ 229 | { 230 | Kinds: []int{protocol.KindEphemeralEvent}, 231 | Authors: []string{publicKey}, 232 | Since: &now, 233 | Tags: nostr.TagMap{ 234 | "p": []string{signedEvent.PubKey}, 235 | }, 236 | }, 237 | }, 238 | ) 239 | nc.subscriptionChan = incomingEventChannel 240 | } 241 | return signedEvent, nil 242 | } 243 | 244 | func (nc *NostrConnection) publishEventToRelays(ev nostr.Event, relays []string) error { 245 | for _, responseRelay := range relays { 246 | var relay *nostr.Relay 247 | relay, err := nc.pool.EnsureRelay(responseRelay) 248 | if err != nil { 249 | return fmt.Errorf("could not ensure relay: %w", err) 250 | } 251 | err = relay.Publish(nc.ctx, ev) 252 | if err != nil { 253 | return fmt.Errorf("could not publish event to relay: %w", err) 254 | } 255 | } 256 | return nil 257 | } 258 | 259 | func (nc *NostrConnection) appendSentBytes(b []byte) { 260 | nc.sentBytes = append(nc.sentBytes, b) 261 | } 262 | 263 | // parseDestination takes a destination string and returns a public key and relays. 264 | // The destination can be "npub" or "nprofile". 265 | // If the prefix is "npub", the public key is extracted. 266 | // If the prefix is "nprofile", the public key and relays are extracted. 267 | // Returns the public key, relays (if any), and any error encountered. 268 | func (nc *NostrConnection) parseDestination() (string, []string, error) { 269 | // check if destination ends with .nostr 270 | if strings.HasPrefix(nc.dst, "npub") || strings.HasPrefix(nc.dst, "nprofile") { 271 | // destination can be npub or nprofile 272 | prefix, pubKey, err := nip19.Decode(nc.dst) 273 | 274 | if err != nil { 275 | return "", nil, fmt.Errorf("could not decode destination: %w", err) 276 | } 277 | 278 | var relays []string 279 | var publicKey string 280 | 281 | switch prefix { 282 | case "npub": 283 | publicKey = pubKey.(string) 284 | case "nprofile": 285 | profilePointer := pubKey.(nostr.ProfilePointer) 286 | publicKey = profilePointer.PublicKey 287 | relays = profilePointer.Relays 288 | } 289 | return publicKey, relays, nil 290 | } 291 | return nc.parseDestinationDomain() 292 | } 293 | 294 | func (nc *NostrConnection) parseDestinationDomain() (string, []string, error) { 295 | url, err := protocol.Parse(nc.dst) 296 | if err != nil { 297 | return "", nil, err 298 | } 299 | if !url.IsDomain { 300 | // try to parse as ip 301 | ip := net.ParseIP(url.Name) 302 | if ip != nil { 303 | return nc.targetPublicKey, nc.defaultRelays, nil 304 | } 305 | return "", nil, fmt.Errorf("destination is not a domain") 306 | 307 | } 308 | if url.TLD != "nostr" { 309 | // parse public key 310 | /*pubKey,err := nostr.GetPublicKey(nc.privateKey) 311 | if err != nil { 312 | return "", nil, err 313 | }*/ 314 | return nc.targetPublicKey, nc.defaultRelays, nil 315 | } 316 | subdomains := make([]string, 0) 317 | split := strings.Split(url.SubName, ".") 318 | for _, subdomain := range split { 319 | decodedSubDomain, err := base32.HexEncoding.WithPadding(base32.NoPadding).DecodeString(strings.ToUpper(subdomain)) 320 | if err != nil { 321 | continue 322 | } 323 | subdomains = append(subdomains, string(decodedSubDomain)) 324 | } 325 | 326 | // base32 decode the subdomain 327 | decodedPubKey, err := base32.HexEncoding.WithPadding(base32.NoPadding).DecodeString(strings.ToUpper(url.Name)) 328 | 329 | if err != nil { 330 | return "", nil, err 331 | } 332 | pk, err := schnorr.ParsePubKey(decodedPubKey) 333 | if err != nil { 334 | return "", nil, err 335 | } 336 | // todo -- check if this is correct 337 | return hex.EncodeToString(pk.SerializeCompressed())[2:], subdomains, nil 338 | } 339 | 340 | func (nc *NostrConnection) Close() error { 341 | nc.cancel() 342 | return nil 343 | } 344 | 345 | func (nc *NostrConnection) LocalAddr() net.Addr { 346 | return &net.TCPAddr{IP: net.ParseIP("127.0.0.1"), Port: 9333} 347 | } 348 | 349 | func (nc *NostrConnection) RemoteAddr() net.Addr { 350 | return &net.TCPAddr{IP: net.ParseIP("127.0.0.1"), Port: 0} 351 | } 352 | 353 | func (nc *NostrConnection) SetDeadline(_ time.Time) error { 354 | return nil 355 | } 356 | 357 | func (nc *NostrConnection) SetReadDeadline(_ time.Time) error { 358 | return nil 359 | } 360 | 361 | func (nc *NostrConnection) SetWriteDeadline(_ time.Time) error { 362 | return nil 363 | } 364 | 365 | // NostrConnOption is a functional option type for configuring NostrConnConfig. 366 | type NostrConnOption func(*NostrConnection) 367 | 368 | // WithPrivateKey sets the private key for the NostrConnConfig. 369 | func WithPrivateKey(privateKey string) NostrConnOption { 370 | return func(config *NostrConnection) { 371 | config.privateKey = privateKey 372 | } 373 | } 374 | 375 | // WithPrivateKey sets the private key for the NostrConnConfig. 376 | func WithDefaultRelays(defaultRelays []string) NostrConnOption { 377 | return func(config *NostrConnection) { 378 | config.defaultRelays = defaultRelays 379 | } 380 | } 381 | 382 | // WithTargetPublicKey sets the private key for the NostrConnConfig. 383 | func WithTargetPublicKey(pubKey string) NostrConnOption { 384 | return func(config *NostrConnection) { 385 | config.targetPublicKey = pubKey 386 | } 387 | } 388 | 389 | // WithSub is a function that returns a NostrConnOption. When this option is applied 390 | // to a NostrConnConfig, it sets the 'sub' field to true, indicating that 391 | // the connection will handle subscriptions. 392 | func WithSub(...bool) NostrConnOption { 393 | return func(connection *NostrConnection) { 394 | connection.sub = true 395 | //go connection.handleSubscription() 396 | } 397 | } 398 | 399 | // WithDst is a NostrConnOption function that sets the destination address for the Nostr connection configuration. 400 | // It takes a string parameter `dst` and updates the `config.dst` field accordingly. 401 | func WithDst(dst string) NostrConnOption { 402 | return func(connection *NostrConnection) { 403 | connection.dst = dst 404 | } 405 | } 406 | 407 | // WithUUID sets the UUID option for creating a NostrConnConfig. 408 | // It assigns the provided UUID to the config's uuid field. 409 | func WithUUID(uuid uuid.UUID) NostrConnOption { 410 | return func(connection *NostrConnection) { 411 | connection.uuid = uuid 412 | } 413 | } 414 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/aead/siphash v1.0.1/go.mod h1:Nywa3cDsYNNK3gaciGTWPwHt0wlpNV15vwmswBAUSII= 2 | github.com/btcsuite/btcd v0.20.1-beta/go.mod h1:wVuoA8VJLEcwgqHBwHmzLRazpKxTv13Px/pDuV7OomQ= 3 | github.com/btcsuite/btcd v0.22.0-beta.0.20220111032746-97732e52810c/go.mod h1:tjmYdS6MLJ5/s0Fj4DbLgSbDHbEqLJrtnHecBFkdz5M= 4 | github.com/btcsuite/btcd v0.23.0/go.mod h1:0QJIIN1wwIXF/3G/m87gIwGniDMDQqjVn4SZgnFpsYY= 5 | github.com/btcsuite/btcd/btcec/v2 v2.1.0/go.mod h1:2VzYrv4Gm4apmbVVsSq5bqf1Ec8v56E48Vt0Y/umPgA= 6 | github.com/btcsuite/btcd/btcec/v2 v2.1.3/go.mod h1:ctjw4H1kknNJmRN4iP1R7bTQ+v3GJkZBd6mui8ZsAZE= 7 | github.com/btcsuite/btcd/btcec/v2 v2.3.2 h1:5n0X6hX0Zk+6omWcihdYvdAlGf2DfasC0GMf7DClJ3U= 8 | github.com/btcsuite/btcd/btcec/v2 v2.3.2/go.mod h1:zYzJ8etWJQIv1Ogk7OzpWjowwOdXY1W/17j2MW85J04= 9 | github.com/btcsuite/btcd/btcutil v1.0.0/go.mod h1:Uoxwv0pqYWhD//tfTiipkxNfdhG9UrLwaeswfjfdF0A= 10 | github.com/btcsuite/btcd/btcutil v1.1.0/go.mod h1:5OapHB7A2hBBWLm48mmw4MOHNJCcUBTwmWH/0Jn8VHE= 11 | github.com/btcsuite/btcd/btcutil v1.1.3 h1:xfbtw8lwpp0G6NwSHb+UE67ryTFHJAiNuipusjXSohQ= 12 | github.com/btcsuite/btcd/btcutil v1.1.3/go.mod h1:UR7dsSJzJUfMmFiiLlIrMq1lS9jh9EdCV7FStZSnpi0= 13 | github.com/btcsuite/btcd/chaincfg/chainhash v1.0.0/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc= 14 | github.com/btcsuite/btcd/chaincfg/chainhash v1.0.1/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc= 15 | github.com/btcsuite/btcd/chaincfg/chainhash v1.0.2 h1:KdUfX2zKommPRa+PD0sWZUyXe9w277ABlgELO7H04IM= 16 | github.com/btcsuite/btcd/chaincfg/chainhash v1.0.2/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc= 17 | github.com/btcsuite/btclog v0.0.0-20170628155309-84c8d2346e9f/go.mod h1:TdznJufoqS23FtqVCzL0ZqgP5MqXbb4fg/WgDys70nA= 18 | github.com/btcsuite/btcutil v0.0.0-20190425235716-9e5f4b9a998d/go.mod h1:+5NJ2+qvTyV9exUAL/rxXi3DcLg2Ts+ymUAY5y4NvMg= 19 | github.com/btcsuite/go-socks v0.0.0-20170105172521-4720035b7bfd/go.mod h1:HHNXQzUsZCxOoE+CPiyCTO6x34Zs86zZUiwtpXoGdtg= 20 | github.com/btcsuite/goleveldb v0.0.0-20160330041536-7834afc9e8cd/go.mod h1:F+uVaaLLH7j4eDXPRvw78tMflu7Ie2bzYOH4Y8rRKBY= 21 | github.com/btcsuite/goleveldb v1.0.0/go.mod h1:QiK9vBlgftBg6rWQIj6wFzbPfRjiykIEhBH4obrXJ/I= 22 | github.com/btcsuite/snappy-go v0.0.0-20151229074030-0bdef8d06723/go.mod h1:8woku9dyThutzjeg+3xrA5iCpBRH8XEEg3lh6TiUghc= 23 | github.com/btcsuite/snappy-go v1.0.0/go.mod h1:8woku9dyThutzjeg+3xrA5iCpBRH8XEEg3lh6TiUghc= 24 | github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792/go.mod h1:ghJtEyQwv5/p4Mg4C0fgbePVuGr935/5ddU9Z3TmDRY= 25 | github.com/btcsuite/winsvc v1.0.0/go.mod h1:jsenWakMcC0zFBFurPLEAyrnc/teJEM1O46fmI40EZs= 26 | github.com/caarlos0/env/v11 v11.0.0 h1:ZIlkOjuL3xoZS0kmUJlF74j2Qj8GMOq3CDLX/Viak8Q= 27 | github.com/caarlos0/env/v11 v11.0.0/go.mod h1:2RC3HQu8BQqtEK3V4iHPxj0jOdWdbPpWJ6pOueeU1xM= 28 | github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= 29 | github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 30 | github.com/davecgh/go-spew v0.0.0-20171005155431-ecdeabc65495/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 31 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 32 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 33 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 34 | github.com/decred/dcrd/crypto/blake256 v1.0.0/go.mod h1:sQl2p6Y26YV+ZOcSTP6thNdn47hh8kt6rqSlvmrXFAc= 35 | github.com/decred/dcrd/crypto/blake256 v1.0.1 h1:7PltbUIQB7u/FfZ39+DGa/ShuMyJ5ilcvdfma9wOH6Y= 36 | github.com/decred/dcrd/crypto/blake256 v1.0.1/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo= 37 | github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1/go.mod h1:hyedUtir6IdtD/7lIxGeCxkaw7y45JueMRL4DIyJDKs= 38 | github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 h1:8UrgZ3GkP4i/CLijOJx79Yu+etlyjdBU4sfcs2WYQMs= 39 | github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0= 40 | github.com/decred/dcrd/lru v1.0.0/go.mod h1:mxKOwFd7lFjN2GZYsiz/ecgqR6kkYAl+0pz0tEMk218= 41 | github.com/ekzyis/nip44 v0.0.0-20240425094820-6a3d864c8f08 h1:4/W0TU+JXOxZhWYQKqGEnEHigOym+ilZUiWwtD/KQNo= 42 | github.com/ekzyis/nip44 v0.0.0-20240425094820-6a3d864c8f08/go.mod h1:SWpq7RYr0raQqGwJaSaCCWzKIuPHB+SmALuhCZd1dWI= 43 | github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= 44 | github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= 45 | github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU= 46 | github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM= 47 | github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og= 48 | github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= 49 | github.com/gobwas/ws v1.2.0 h1:u0p9s3xLYpZCA1z5JgCkMeB34CKCMMQbM+G8Ii7YD0I= 50 | github.com/gobwas/ws v1.2.0/go.mod h1:hRKAFb8wOxFROYNsT1bqfWnhX+b5MFeJM9r2ZSwg/KY= 51 | github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 52 | github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= 53 | github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= 54 | github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= 55 | github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= 56 | github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= 57 | github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= 58 | github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= 59 | github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 60 | github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 61 | github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 62 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 63 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 64 | github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= 65 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 66 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 67 | github.com/jessevdk/go-flags v0.0.0-20141203071132-1679536dcc89/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= 68 | github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= 69 | github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= 70 | github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= 71 | github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= 72 | github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= 73 | github.com/jrick/logrotate v1.0.0/go.mod h1:LNinyqDIJnpAur+b8yyulnQw/wDuN1+BYKlTRt3OuAQ= 74 | github.com/kkdai/bstream v0.0.0-20161212061736-f391b8402d23/go.mod h1:J+Gs4SYgM6CZQHDETBtE9HaSEkGmuNXF86RwHhHUvq4= 75 | github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= 76 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 77 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 78 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 79 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 80 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 81 | github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= 82 | github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= 83 | github.com/nbd-wtf/go-nostr v0.30.2 h1:dG/2X52/XDg+7phZH+BClcvA5D+S6dXvxJKkBaySEzI= 84 | github.com/nbd-wtf/go-nostr v0.30.2/go.mod h1:tiKJY6fWYSujbTQb201Y+IQ3l4szqYVt+fsTnsm7FCk= 85 | github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= 86 | github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= 87 | github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= 88 | github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= 89 | github.com/onsi/ginkgo v1.14.0/go.mod h1:iSB4RoI2tjJc9BBv4NKIKWKya62Rps+oPG/Lv9klQyY= 90 | github.com/onsi/gomega v1.4.1/go.mod h1:C1qb7wdrVGGVU+Z6iS04AVkA3Q65CEZX59MT0QO5uiA= 91 | github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= 92 | github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= 93 | github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= 94 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 95 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 96 | github.com/puzpuzpuz/xsync/v3 v3.0.2 h1:3yESHrRFYr6xzkz61LLkvNiPFXxJEAABanTQpKbAaew= 97 | github.com/puzpuzpuz/xsync/v3 v3.0.2/go.mod h1:VjzYrABPabuM4KyBh1Ftq6u8nhwY5tBPKP9jpmh0nnA= 98 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 99 | github.com/samber/lo v1.45.0 h1:TPK85Y30Lv9Jh8s3TrJeA94u1hwcbFA9JObx/vT6lYU= 100 | github.com/samber/lo v1.45.0/go.mod h1:RmDH9Ct32Qy3gduHQuKJ3gW1fMHAnE/fAzQuf6He5cU= 101 | github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= 102 | github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= 103 | github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= 104 | github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 105 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 106 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 107 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= 108 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 109 | github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7/go.mod h1:q4W45IWZaF22tdD+VEXcAWRA037jwmWEB5VWYORlTpc= 110 | github.com/tidwall/gjson v1.14.4 h1:uo0p8EbA09J7RQaflQ1aBRffTR7xedD2bcIVSYxLnkM= 111 | github.com/tidwall/gjson v1.14.4/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= 112 | github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= 113 | github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= 114 | github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs= 115 | github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= 116 | golang.org/x/crypto v0.0.0-20170930174604-9419663f5a44/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= 117 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 118 | golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 119 | golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30= 120 | golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M= 121 | golang.org/x/exp v0.0.0-20230425010034-47ecfdc1ba53 h1:5llv2sWeaMSnA3w2kS57ouQQ4pudlXrR0dCgw51QK9o= 122 | golang.org/x/exp v0.0.0-20230425010034-47ecfdc1ba53/go.mod h1:V1LtkGg67GoY2N1AnLN78QLrzxkLyJw7RJb1gzOOz9w= 123 | golang.org/x/net v0.0.0-20180719180050-a680a1efc54d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 124 | golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 125 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 126 | golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= 127 | golang.org/x/net v0.0.0-20200813134508-3edf25e44fcc/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= 128 | golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys= 129 | golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE= 130 | golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 131 | golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 132 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 133 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 134 | golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 135 | golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 136 | golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 137 | golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 138 | golang.org/x/sys v0.0.0-20200519105757-fe76b779f299/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 139 | golang.org/x/sys v0.0.0-20200814200057-3d37ad5750ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 140 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 141 | golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= 142 | golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 143 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 144 | golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= 145 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 146 | golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= 147 | golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= 148 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 149 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 150 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 151 | google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= 152 | google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= 153 | google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= 154 | google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= 155 | google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= 156 | google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= 157 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 158 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= 159 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 160 | gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= 161 | gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= 162 | gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 163 | gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 164 | gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 165 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 166 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 167 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 168 | --------------------------------------------------------------------------------