├── 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 [](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 |
--------------------------------------------------------------------------------