add chatting example
Kim committed
Oct 21, 2025 at 16:27 UTC
36a5f7ee152b7fb67fc003aad27064079e37b765
12 files changed
+492
-129
Makefile
+4
-4
@@ -28,10 +28,10 @@ CLIENT_FLAGS := \
28
$(CLIENT_BOOTSTRAPS_FLAGS)
29
30
client-run:
31
- go run ./cmd/example_client $(CLIENT_FLAGS)
31
+ go run ./cmd/example_http_client $(CLIENT_FLAGS)
32
33
client-build:
34
- go build -trimpath -o bin/relaydns-client ./cmd/example_client
34
+ go build -trimpath -o bin/relaydns-client ./cmd/example_http_client
35
36
# ---------- Dev helpers ----------
37
fmt:
@@ -45,7 +45,7 @@ help:
45
@echo " make server-up # build and start relayserver (docker compose)"
46
@echo " make server-down # stop and remove containers"
47
@echo "\nClient:"
48
- @echo " make client-run # run example_client locally with minimal flags"
49
- @echo " make client-build # build example_client to ./bin/relaydns-client"
48
+ @echo " make client-run # run example_http_client locally with minimal flags"
49
+ @echo " make client-build # build example_http_client to ./bin/relaydns-client"
50
@echo "\nFlags (override with make VAR=value):"
51
@echo " SERVER_URL BACKEND_HTTP BOOTSTRAPS"
cmd/example_chat/main.go
new
+91
@@ -0,0 +1,91 @@
1
+package main
2
+
3
+import (
4
+ "context"
5
+ "flag"
6
+ "fmt"
7
+ "net"
8
+ "net/http"
9
+ "os"
10
+ "os/signal"
11
+ "syscall"
12
+ "time"
13
+
14
+ "github.com/gosuda/relaydns/relaydns"
15
+ "github.com/rs/zerolog/log"
16
+)
17
+
18
+var (
19
+ flagServerURL string
20
+ flagBootstraps relaydns.StringSlice
21
+ flagAddr string
22
+ flagName string
23
+)
24
+
25
+func init() {
26
+ flag.StringVar(&flagServerURL, "server-url", "http://localhost:8080", "relayserver base URL to fetch multiaddrs from /health")
27
+ flag.Var(&flagBootstraps, "bootstrap", "multiaddr with /p2p/ (repeat). supports /dnsaddr/")
28
+ flag.StringVar(&flagAddr, "addr", ":8091", "local chat HTTP listen address")
29
+ flag.StringVar(&flagName, "name", "demo-chat", "backend display name")
30
+}
31
+
32
+func main() {
33
+ flag.Parse()
34
+ if err := run(); err != nil {
35
+ log.Fatal().Err(err).Msg("example_chat")
36
+ }
37
+}
38
+
39
+func run() error {
40
+ ctx, cancel := context.WithCancel(context.Background())
41
+ defer cancel()
42
+
43
+ // 1) start local chat HTTP backend
44
+ ln, err := net.Listen("tcp", flagAddr)
45
+ if err != nil {
46
+ return fmt.Errorf("listen chat: %w", err)
47
+ }
48
+ hub := newHub()
49
+ mux := http.NewServeMux()
50
+ mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { serveIndex(w, r, flagName) })
51
+ mux.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) { handleWS(w, r, hub) })
52
+ srv := &http.Server{Handler: mux, ReadHeaderTimeout: 5 * time.Second, IdleTimeout: 60 * time.Second}
53
+ go func() {
54
+ log.Info().Msgf("[chat] http listening on %s", ln.Addr().String())
55
+ if err := srv.Serve(ln); err != nil && err != http.ErrServerClosed {
56
+ log.Error().Err(err).Msg("chat http error")
57
+ cancel()
58
+ }
59
+ }()
60
+
61
+ // 2) advertise over RelayDNS (HTTP tunneled via server /peer route)
62
+ client, err := relaydns.NewClient(ctx, relaydns.ClientConfig{
63
+ Protocol: "/relaydns/http/1.0",
64
+ Topic: "relaydns.backends",
65
+ AdvertiseEvery: 3 * time.Second,
66
+ Name: flagName,
67
+ TargetTCP: relaydns.AddrToTarget(flagAddr),
68
+
69
+ ServerURL: flagServerURL,
70
+ Bootstraps: flagBootstraps,
71
+ HTTPTimeout: 5 * time.Second,
72
+ PreferQUIC: true,
73
+ PreferLocal: true,
74
+ })
75
+ if err != nil {
76
+ return fmt.Errorf("new client: %w", err)
77
+ }
78
+ if err := client.Start(ctx); err != nil {
79
+ return fmt.Errorf("start client: %w", err)
80
+ }
81
+ defer client.Close()
82
+
83
+ sig := make(chan os.Signal, 1)
84
+ signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
85
+ <-sig
86
+ cancel()
87
+ shutCtx, cancelFn := context.WithTimeout(context.Background(), 2*time.Second)
88
+ _ = srv.Shutdown(shutCtx)
89
+ cancelFn()
90
+ return nil
91
+}
cmd/example_chat/view.go
new
+225
@@ -0,0 +1,225 @@
1
+package main
2
+
3
+import (
4
+ "context"
5
+ "html/template"
6
+ "net/http"
7
+ "sync"
8
+ "time"
9
+
10
+ "github.com/coder/websocket"
11
+ "github.com/coder/websocket/wsjson"
12
+)
13
+
14
+// simple in-memory chat hub
15
+type hub struct {
16
+ mu sync.RWMutex
17
+ messages []message
18
+ conns map[*websocket.Conn]struct{}
19
+}
20
+
21
+type message struct {
22
+ TS time.Time `json:"ts"`
23
+ User string `json:"user"`
24
+ Text string `json:"text"`
25
+}
26
+
27
+func newHub() *hub {
28
+ return &hub{conns: map[*websocket.Conn]struct{}{}, messages: make([]message, 0, 64)}
29
+}
30
+
31
+func (h *hub) broadcast(m message) {
32
+ h.mu.Lock()
33
+ h.messages = append(h.messages, m)
34
+ conns := make([]*websocket.Conn, 0, len(h.conns))
35
+ for c := range h.conns {
36
+ conns = append(conns, c)
37
+ }
38
+ h.mu.Unlock()
39
+ for _, c := range conns {
40
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
41
+ _ = wsjson.Write(ctx, c, m)
42
+ cancel()
43
+ }
44
+}
45
+
46
+func handleWS(w http.ResponseWriter, r *http.Request, h *hub) {
47
+ conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{
48
+ // Allow any origin for demo simplicity. Consider tightening in production.
49
+ OriginPatterns: []string{"*"},
50
+ })
51
+ if err != nil {
52
+ return
53
+ }
54
+ // Use a connection-scoped context not tied to the HTTP request lifecycle.
55
+ connCtx, cancelConn := context.WithCancel(context.Background())
56
+ h.mu.Lock()
57
+ h.conns[conn] = struct{}{}
58
+ backlog := append([]message(nil), h.messages...)
59
+ h.mu.Unlock()
60
+ if len(backlog) > 20 {
61
+ backlog = backlog[len(backlog)-20:]
62
+ }
63
+ for _, m := range backlog {
64
+ _ = wsjson.Write(connCtx, conn, m)
65
+ }
66
+ go func() {
67
+ defer func() {
68
+ h.mu.Lock()
69
+ delete(h.conns, conn)
70
+ h.mu.Unlock()
71
+ conn.Close(websocket.StatusNormalClosure, "")
72
+ cancelConn()
73
+ }()
74
+ for {
75
+ var req struct {
76
+ User string `json:"user"`
77
+ Text string `json:"text"`
78
+ }
79
+ if err := wsjson.Read(connCtx, conn, &req); err != nil {
80
+ return
81
+ }
82
+ if req.User == "" {
83
+ req.User = "anon"
84
+ }
85
+ if req.Text == "" {
86
+ continue
87
+ }
88
+ h.broadcast(message{TS: time.Now().UTC(), User: req.User, Text: req.Text})
89
+ }
90
+ }()
91
+}
92
+
93
+func serveIndex(w http.ResponseWriter, r *http.Request, name string) {
94
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
95
+ _ = indexTmpl.Execute(w, struct{ Name string }{Name: name})
96
+}
97
+
98
+var indexTmpl = template.Must(template.New("chat").Parse(`<!DOCTYPE html>
99
+<html lang="en">
100
+<head>
101
+ <meta charset="utf-8" />
102
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
103
+ <title>RelayDNS Chat — {{.Name}}</title>
104
+ <style>
105
+ :root{
106
+ --bg: #0d1117;
107
+ --panel: #111827;
108
+ --border: #1f2937;
109
+ --fg: #e5e7eb;
110
+ --muted: #9ca3af;
111
+ --accent: #22c55e;
112
+ --cursor: #22c55e;
113
+ }
114
+ *{ box-sizing: border-box }
115
+ body { margin:0; padding:24px; background:var(--bg); color:var(--fg); font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial }
116
+ .wrap { max-width: 920px; margin: 0 auto }
117
+ h1 { margin:0 0 12px 0; font-weight:700 }
118
+ .term { border:1px solid var(--border); border-radius:10px; background:var(--panel); overflow:hidden }
119
+ .termbar { display:flex; align-items:center; justify-content:space-between; padding:10px 12px; border-bottom:1px solid var(--border); font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size:14px }
120
+ .dots { display:flex; gap:6px }
121
+ .dot { width:10px; height:10px; border-radius:50%; }
122
+ .dot.red{ background:#ef4444 }
123
+ .dot.yellow{ background:#f59e0b }
124
+ .dot.green{ background:#22c55e }
125
+ .nick { display:flex; align-items:center; gap:8px }
126
+ .nick input{ background:transparent; border:1px solid var(--border); color:var(--fg); padding:6px 8px; border-radius:6px; font-family:inherit; font-size:13px; width:180px }
127
+ .nick button{ background:transparent; border:1px solid var(--border); color:var(--fg); padding:6px 8px; border-radius:6px; font-family:inherit; font-size:13px; cursor:pointer }
128
+ .screen { height:420px; overflow:auto; padding:14px; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size:14px; line-height:1.5; }
129
+ .line { white-space: pre-wrap; word-break: break-word }
130
+ .ts { color:var(--muted) }
131
+ .usr { color:#60a5fa }
132
+ .promptline { display:flex; align-items:center; gap:8px; padding:12px 14px; border-top:1px solid var(--border); font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
133
+ #prompt { color:var(--accent) }
134
+ #cmd { flex:1; background:transparent; border:none; outline:none; color:var(--fg); font-family: inherit; font-size:14px; caret-color: var(--cursor) }
135
+ small{ color:var(--muted); display:block; margin-top:10px }
136
+ </style>
137
+</head>
138
+<body>
139
+ <div class="wrap">
140
+ <h1>🔐 Chatting — {{.Name}}</h1>
141
+ <div class="term">
142
+ <div class="termbar">
143
+ <div class="dots"><span class="dot red"></span><span class="dot yellow"></span><span class="dot green"></span></div>
144
+ <div style="opacity:.9">relaychat@relaydns</div>
145
+ <div class="nick">
146
+ <label for="user" style="color:var(--muted)">nick</label>
147
+ <input id="user" type="text" placeholder="anon" />
148
+ <button id="roll" title="randomize nickname">🎲</button>
149
+ </div>
150
+ </div>
151
+ <div id="log" class="screen"></div>
152
+ <div class="promptline">
153
+ <span id="prompt"></span>
154
+ <input id="cmd" type="text" autocomplete="off" spellcheck="false" placeholder="type a message and press Enter" />
155
+ </div>
156
+ </div>
157
+ <small>Tip: Enter to send • Nickname persists locally</small>
158
+ </div>
159
+ <script>
160
+ const log = document.getElementById('log');
161
+ const user = document.getElementById('user');
162
+ const cmd = document.getElementById('cmd');
163
+ const roll = document.getElementById('roll');
164
+ const promptEl = document.getElementById('prompt');
165
+
166
+ function setPrompt(){
167
+ const nick = (user.value || 'anon').replace(/\s+/g,'').slice(0,24) || 'anon';
168
+ promptEl.textContent = nick + '@chat:~$';
169
+ }
170
+ function randomNick(){
171
+ const adjs = ['brisk','calm','clever','daring','eager','gentle','merry','rapid','vivid','bright'];
172
+ const animals = ['fox','lion','panda','otter','eagle','whale','koala','lynx','squid','yak'];
173
+ const a = adjs[Math.floor(Math.random()*adjs.length)];
174
+ const b = animals[Math.floor(Math.random()*animals.length)];
175
+ const id = Math.random().toString(36).slice(2,6);
176
+ return a + '-' + b + '-' + id;
177
+ }
178
+ // Restore nickname or initialize randomly
179
+ let savedNick = null;
180
+ try { savedNick = localStorage.getItem('relaydns_nick'); } catch(_) {}
181
+ if(savedNick){
182
+ user.value = savedNick;
183
+ } else {
184
+ user.value = randomNick();
185
+ try { localStorage.setItem('relaydns_nick', user.value); } catch(_) {}
186
+ }
187
+ setPrompt();
188
+ user.addEventListener('input', () => { try{ localStorage.setItem('relaydns_nick', user.value); }catch(_){}; setPrompt(); });
189
+ roll.addEventListener('click', () => {
190
+ user.value = randomNick();
191
+ try{ localStorage.setItem('relaydns_nick', user.value); }catch(_){}
192
+ setPrompt();
193
+ user.focus();
194
+ });
195
+
196
+ function append(msg){
197
+ const div = document.createElement('div');
198
+ div.className = 'line';
199
+ const ts = new Date(msg.ts).toLocaleTimeString();
200
+ div.innerHTML = '<span class="ts">[' + ts + ']</span> <span class="usr">' +
201
+ (msg.user || 'anon') + '</span>: ' + escapeHTML(msg.text || '');
202
+ log.appendChild(div);
203
+ log.scrollTop = log.scrollHeight;
204
+ }
205
+ function escapeHTML(s){
206
+ return s.replace(/[&<>\"]/g, c => ({'&':'&','<':'<','>':'>','\"':'"'}[c]));
207
+ }
208
+
209
+ const wsProto = location.protocol === 'https:' ? 'wss' : 'ws';
210
+ const ws = new WebSocket(wsProto + '://' + location.host + location.pathname + 'ws');
211
+ ws.onmessage = (e) => { try{ append(JSON.parse(e.data)); }catch(_){ } };
212
+ function send(){
213
+ const payload = { user: (user.value || 'anon'), text: cmd.value.trim() };
214
+ if(!payload.text) return;
215
+ ws.send(JSON.stringify(payload));
216
+ cmd.value='';
217
+ }
218
+ cmd.addEventListener('keydown', e => {
219
+ if(e.key === 'Enter') { e.preventDefault(); send(); }
220
+ });
221
+ // Focus command line on load
222
+ setTimeout(()=>cmd.focus(), 0);
223
+ </script>
224
+</body>
225
+</html>`))
cmd/example_http_client/main.go
renamed
+5
-12
@@ -76,11 +76,11 @@ func runClient(cmd *cobra.Command, args []string) error {
76
77
// 2) libp2p host
78
client, err := relaydns.NewClient(ctx, relaydns.ClientConfig{
79
- Protocol: flagProtocol,
80
- Topic: flagTopic,
81
- Advertise: 3 * time.Second,
82
- Name: flagClientName,
83
- TargetTCP: addrToTarget(flagAddr),
79
+ Protocol: flagProtocol,
80
+ Topic: flagTopic,
81
+ AdvertiseEvery: 3 * time.Second,
82
+ Name: flagClientName,
83
+ TargetTCP: relaydns.AddrToTarget(flagAddr),
84
85
ServerURL: flagServerURL,
86
Bootstraps: flagBootstraps,
@@ -105,10 +105,3 @@ func runClient(cmd *cobra.Command, args []string) error {
105
time.Sleep(200 * time.Millisecond)
106
return nil
107
}
108
-
109
-func addrToTarget(listen string) string {
110
- if len(listen) > 0 && listen[0] == ':' {
111
- return "127.0.0.1" + listen
112
- }
113
- return listen
114
-}
cmd/example_http_client/view.go
renamed
cmd/server/view.go
+9
-16
@@ -14,7 +14,7 @@ import (
14
"github.com/rs/zerolog/log"
15
)
16
17
-// serveHTTP builds the HTTP mux and starts serving admin UI + per-peer proxy.
17
+// serveHTTP builds the HTTP mux.
18
func serveHTTP(ctx context.Context, addr string, d *relaydns.Director, h host.Host, cancel context.CancelFunc) {
19
if addr == "" {
20
return
@@ -33,12 +33,13 @@ func serveHTTP(ctx context.Context, addr string, d *relaydns.Director, h host.Ho
33
if v.Info.TTL > 0 {
34
ttl = fmt.Sprintf("%ds", v.Info.TTL)
35
}
36
+ // Derive a friendly kind from protocol id. Be lenient to variations.
37
+ p := strings.ToLower(v.Info.Proto)
38
kind := "TCP"
37
- if strings.Contains(v.Info.Proto, "/http/") {
38
- kind = "HTTP"
39
- }
40
- if strings.Contains(v.Info.Proto, "/ssh/") {
39
+ if strings.Contains(p, "ssh") {
40
kind = "SSH"
41
+ } else if strings.Contains(p, "http") {
42
+ kind = "HTTP"
43
}
44
rows = append(rows, row{
45
Peer: v.Info.Peer,
@@ -55,7 +56,7 @@ func serveHTTP(ctx context.Context, addr string, d *relaydns.Director, h host.Ho
56
log.Debug().Int("clients", len(rows)).Msg("render admin index")
57
_ = adminIndexTmpl.Execute(w, page{
58
NodeID: h.ID().String(),
58
- Addrs: buildAddrs(h),
59
+ Addrs: relaydns.BuildAddrs(h),
60
Rows: rows,
61
})
62
})
@@ -87,12 +88,12 @@ func serveHTTP(ctx context.Context, addr string, d *relaydns.Director, h host.Ho
88
Status string `json:"status"`
89
Addrs []string `json:"multiaddrs"`
90
}
90
- resp := info{Status: "ok", Addrs: buildAddrs(h)}
91
+ resp := info{Status: "ok", Addrs: relaydns.BuildAddrs(h)}
92
w.Header().Set("Content-Type", "application/json")
93
_ = json.NewEncoder(w).Encode(resp)
94
})
95
95
- log.Info().Msgf("[server] http (admin+proxy): %s", addr)
96
+ log.Info().Msgf("[server] http: %s", addr)
97
if err := http.ListenAndServe(addr, mux); err != nil {
98
log.Error().Err(err).Msg("[server] http error")
99
cancel()
@@ -117,14 +118,6 @@ type page struct {
118
Rows []row
119
}
120
120
-func buildAddrs(h host.Host) []string {
121
- out := make([]string, 0)
122
- for _, a := range h.Addrs() {
123
- out = append(out, fmt.Sprintf("%s/p2p/%s", a.String(), h.ID().String()))
124
- }
125
- return out
126
-}
127
-
121
var adminIndexTmpl = template.Must(template.New("admin-index").Parse(`<!doctype html>
122
<html lang="ko">
123
<head>
go.mod
+1
@@ -3,6 +3,7 @@ module github.com/gosuda/relaydns
3
go 1.25.0
4
5
require (
6
+ github.com/coder/websocket v1.8.14
7
github.com/libp2p/go-libp2p v0.44.0
8
github.com/libp2p/go-libp2p-pubsub v0.15.0
9
github.com/multiformats/go-multiaddr v0.16.0
go.sum
+2
@@ -19,6 +19,8 @@ github.com/buger/jsonparser v0.0.0-20181115193947-bf1c66bbce23/go.mod h1:bbYlZJ7
19
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
20
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
21
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
22
+github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
23
+github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
24
github.com/coreos/go-systemd v0.0.0-20181012123002-c6f51f82210d/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
25
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
26
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
relaydns/client.go
+9
-87
@@ -3,14 +3,9 @@ package relaydns
3
import (
4
"context"
5
"encoding/json"
6
- "errors"
6
"fmt"
7
"io"
8
"net"
10
- "net/http"
11
- "net/url"
12
- "sort"
13
- "strings"
9
"sync"
10
"time"
11
@@ -33,7 +28,7 @@ type ClientConfig struct {
28
// pubsub topic for backend adverts (e.g. "relaydns.backends")
29
Topic string
30
// advertise interval
36
- Advertise time.Duration
31
+ AdvertiseEvery time.Duration
32
// advertise TTL (how long server should keep this entry alive)
33
AdvertiseTTL time.Duration
34
// how often to refresh server health/bootstraps (if ServerURL set)
@@ -66,11 +61,11 @@ type RelayClient struct {
61
// NewClient constructs a client with defaults applied and an initialized libp2p host.
62
// It does not start networking. Call Start(ctx) to begin handlers, pubsub, and advertising.
63
func NewClient(ctx context.Context, cfg ClientConfig) (*RelayClient, error) {
69
- if cfg.Advertise <= 0 {
70
- cfg.Advertise = 5 * time.Second
64
+ if cfg.AdvertiseEvery <= 0 {
65
+ cfg.AdvertiseEvery = 5 * time.Second
66
}
67
if cfg.AdvertiseTTL <= 0 {
73
- cfg.AdvertiseTTL = 10 * cfg.Advertise
68
+ cfg.AdvertiseTTL = 10 * cfg.AdvertiseEvery
69
}
70
if cfg.HTTPTimeout <= 0 {
71
cfg.HTTPTimeout = 3 * time.Second
@@ -162,18 +157,14 @@ func (b *RelayClient) Start(ctx context.Context) error {
157
b.wg.Add(1)
158
go func() {
159
defer b.wg.Done()
165
- ticker := time.NewTicker(b.cfg.Advertise)
160
+ ticker := time.NewTicker(b.cfg.AdvertiseEvery)
161
defer ticker.Stop()
162
for {
163
select {
164
case <-advCtx.Done():
165
return
166
case <-ticker.C:
172
- addrs := b.h.Addrs()
173
- enc := make([]string, 0, len(addrs))
174
- for _, a := range addrs {
175
- enc = append(enc, fmt.Sprintf("%s/p2p/%s", a.String(), b.h.ID().String()))
176
- }
167
+ enc := BuildAddrs(b.h)
168
ad := Advertise{
169
Peer: b.h.ID().String(),
170
Name: b.cfg.Name,
@@ -220,9 +211,9 @@ func (b *RelayClient) Start(ctx context.Context) error {
211
}()
212
}
213
223
- if addrs := b.Host().Addrs(); len(addrs) > 0 {
224
- for _, a := range addrs {
225
- log.Info().Msgf("[client] host addr: %s/p2p/%s", a.String(), b.Host().ID().String())
214
+ if addrs := BuildAddrs(b.Host()); len(addrs) > 0 {
215
+ for _, s := range addrs {
216
+ log.Info().Msgf("[client] host addr: %s", s)
217
}
218
} else {
219
log.Info().Msgf("[client] host peer: %s (no listen addrs yet)", b.Host().ID().String())
@@ -264,72 +255,3 @@ func (b *RelayClient) ServerStatus() string {
255
}
256
return "Connecting..."
257
}
267
-
268
-func fetchMultiaddrsFromHealth(base string, timeout time.Duration) ([]string, error) {
269
- u, err := url.Parse(base)
270
- if err != nil {
271
- return nil, fmt.Errorf("parse server-url: %w", err)
272
- }
273
- // ensure path ends with /health
274
- if !strings.HasSuffix(u.Path, "/health") {
275
- if u.Path == "" || u.Path == "/" {
276
- u.Path = "/health"
277
- } else {
278
- u.Path = strings.TrimSuffix(u.Path, "/") + "/health"
279
- }
280
- }
281
- client := &http.Client{Timeout: timeout}
282
- req, _ := http.NewRequest(http.MethodGet, u.String(), nil)
283
- resp, err := client.Do(req)
284
- if err != nil {
285
- return nil, err
286
- }
287
- defer resp.Body.Close()
288
-
289
- var payload struct {
290
- Status string `json:"status"`
291
- PeerID string `json:"peerId"`
292
- Multiaddrs []string `json:"multiaddrs"`
293
- }
294
- if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
295
- return nil, err
296
- }
297
- if payload.Status != "ok" {
298
- return nil, errors.New("health not ok")
299
- }
300
- addrs := make([]string, 0, len(payload.Multiaddrs))
301
- for _, s := range payload.Multiaddrs {
302
- // sanity check
303
- if strings.Contains(s, "/p2p/") && (strings.Contains(s, "/ip4/") || strings.Contains(s, "/ip6/")) {
304
- addrs = append(addrs, s)
305
- }
306
- }
307
- return addrs, nil
308
-}
309
-
310
-func sortMultiaddrs(addrs []string, preferQUIC, preferLocal bool) {
311
- score := func(a string) int {
312
- sc := 0
313
- if preferQUIC && strings.Contains(a, "/quic-v1") {
314
- sc += 2
315
- }
316
- if preferLocal && (strings.Contains(a, "/ip4/127.0.0.1/") || strings.Contains(a, "/ip6/::1/")) {
317
- sc += 1
318
- }
319
- return sc
320
- }
321
- sort.SliceStable(addrs, func(i, j int) bool { return score(addrs[i]) > score(addrs[j]) })
322
-}
323
-
324
-func uniq(ss []string) []string {
325
- seen := map[string]struct{}{}
326
- out := make([]string, 0, len(ss))
327
- for _, s := range ss {
328
- if _, ok := seen[s]; ok {
329
- continue
330
- }
331
- seen[s] = struct{}{}
332
- out = append(out, s)
333
- }
334
- return out
335
-}
relaydns/director.go
+42
-2
@@ -10,12 +10,14 @@ import (
10
"net/http"
11
"net/url"
12
"sort"
13
+ "strings"
14
"sync"
15
"time"
16
17
pubsub "github.com/libp2p/go-libp2p-pubsub"
18
"github.com/libp2p/go-libp2p/core/host"
19
"github.com/libp2p/go-libp2p/core/peer"
20
+ "github.com/libp2p/go-libp2p/core/protocol"
21
ma "github.com/multiformats/go-multiaddr"
22
"github.com/rs/zerolog/log"
23
)
@@ -187,7 +189,7 @@ func (d *Director) ProxyHTTP(w http.ResponseWriter, r *http.Request, peerID, pat
189
http.Error(w, "upstream connect failed", http.StatusBadGateway)
190
return
191
}
190
- s, err := d.h.NewStream(d.ctx, entry.AddrInfo.ID, protocolID(d.protocol))
192
+ s, err := d.h.NewStream(d.ctx, entry.AddrInfo.ID, protocol.ID(d.protocol))
193
if err != nil {
194
log.Error().Err(err).Msg("new stream")
195
http.Error(w, "open stream failed", http.StatusBadGateway)
@@ -210,12 +212,50 @@ func (d *Director) ProxyHTTP(w http.ResponseWriter, r *http.Request, peerID, pat
212
return
213
}
214
defer resp.Body.Close()
215
+
216
+ // Handle WebSocket Upgrade: write 101 response and then raw-tunnel bytes
217
+ if resp.StatusCode == http.StatusSwitchingProtocols && strings.Contains(strings.ToLower(resp.Header.Get("Upgrade")), "websocket") {
218
+ if hj, ok := w.(http.Hijacker); ok {
219
+ clientConn, clientBuf, err := hj.Hijack()
220
+ if err != nil {
221
+ log.Error().Err(err).Msg("hijack client conn")
222
+ return
223
+ }
224
+ defer clientConn.Close()
225
+ // Write upstream 101 response (headers)
226
+ if err := resp.Write(clientBuf); err == nil {
227
+ _ = clientBuf.Flush()
228
+ }
229
+ // Raw byte tunnel between client and upstream stream
230
+ go io.Copy(s, clientConn)
231
+ io.Copy(clientConn, s)
232
+ return
233
+ }
234
+ }
235
for k, vv := range resp.Header {
236
for _, v := range vv {
237
w.Header().Add(k, v)
238
}
239
}
240
w.WriteHeader(resp.StatusCode)
241
+ // If upstream is SSE, forward with Flush to avoid buffering
242
+ if strings.Contains(strings.ToLower(resp.Header.Get("Content-Type")), "text/event-stream") {
243
+ if f, ok := w.(http.Flusher); ok {
244
+ buf := make([]byte, 4096)
245
+ for {
246
+ n, err := resp.Body.Read(buf)
247
+ if n > 0 {
248
+ if _, werr := w.Write(buf[:n]); werr == nil {
249
+ f.Flush()
250
+ }
251
+ }
252
+ if err != nil {
253
+ break
254
+ }
255
+ }
256
+ return
257
+ }
258
+ }
259
_, _ = io.Copy(w, resp.Body)
260
}
261
@@ -232,7 +272,7 @@ func (d *Director) ProxyTCP(c net.Conn, peerID string) error {
272
if err := d.h.Connect(d.ctx, *entry.AddrInfo); err != nil {
273
return err
274
}
235
- s, err := d.h.NewStream(d.ctx, entry.AddrInfo.ID, protocolID(d.protocol))
275
+ s, err := d.h.NewStream(d.ctx, entry.AddrInfo.ID, protocol.ID(d.protocol))
276
if err != nil {
277
return err
278
}
relaydns/types.go
+1
-8
@@ -4,7 +4,6 @@ import (
4
"time"
5
6
"github.com/libp2p/go-libp2p/core/peer"
7
- "github.com/libp2p/go-libp2p/core/protocol"
7
)
8
9
type Advertise struct {
@@ -15,7 +14,7 @@ type Advertise struct {
14
Ready bool `json:"ready"`
15
Load float64 `json:"load"`
16
TS time.Time `json:"ts"`
18
- TTL int `json:"ttl,omitempty"` // seconds
17
+ TTL int `json:"ttl,omitempty"`
18
Proto string `json:"proto,omitempty"`
19
}
20
@@ -25,9 +24,3 @@ type HostEntry struct {
24
LastSeen time.Time
25
Connected bool
26
}
28
-
29
-// Removed Picker: selection is explicit via /peer/{peerID}/...
30
-
31
-func protocolID(s string) protocol.ID {
32
- return protocol.ID(s)
33
-}
relaydns/utils.go
new
+103
@@ -0,0 +1,103 @@
1
+package relaydns
2
+
3
+import (
4
+ "encoding/json"
5
+ "errors"
6
+ "fmt"
7
+ "net/http"
8
+ "net/url"
9
+ "sort"
10
+ "strings"
11
+ "time"
12
+
13
+ "github.com/libp2p/go-libp2p/core/host"
14
+)
15
+
16
+type StringSlice []string
17
+
18
+func (s *StringSlice) String() string { return fmt.Sprint([]string(*s)) }
19
+func (s *StringSlice) Set(v string) error { *s = append(*s, v); return nil }
20
+
21
+func AddrToTarget(listen string) string {
22
+ if len(listen) > 0 && listen[0] == ':' {
23
+ return "127.0.0.1" + listen
24
+ }
25
+ return listen
26
+}
27
+
28
+func BuildAddrs(h host.Host) []string {
29
+ out := make([]string, 0)
30
+ for _, a := range h.Addrs() {
31
+ out = append(out, fmt.Sprintf("%s/p2p/%s", a.String(), h.ID().String()))
32
+ }
33
+ return out
34
+}
35
+
36
+func sortMultiaddrs(addrs []string, preferQUIC, preferLocal bool) {
37
+ score := func(a string) int {
38
+ sc := 0
39
+ if preferQUIC && strings.Contains(a, "/quic-v1") {
40
+ sc += 2
41
+ }
42
+ if preferLocal && (strings.Contains(a, "/ip4/127.0.0.1/") || strings.Contains(a, "/ip6/::1/")) {
43
+ sc += 1
44
+ }
45
+ return sc
46
+ }
47
+ sort.SliceStable(addrs, func(i, j int) bool { return score(addrs[i]) > score(addrs[j]) })
48
+}
49
+
50
+func uniq(ss []string) []string {
51
+ seen := map[string]struct{}{}
52
+ out := make([]string, 0, len(ss))
53
+ for _, s := range ss {
54
+ if _, ok := seen[s]; ok {
55
+ continue
56
+ }
57
+ seen[s] = struct{}{}
58
+ out = append(out, s)
59
+ }
60
+ return out
61
+}
62
+
63
+func fetchMultiaddrsFromHealth(base string, timeout time.Duration) ([]string, error) {
64
+ u, err := url.Parse(base)
65
+ if err != nil {
66
+ return nil, fmt.Errorf("parse server-url: %w", err)
67
+ }
68
+ // ensure path ends with /health
69
+ if !strings.HasSuffix(u.Path, "/health") {
70
+ if u.Path == "" || u.Path == "/" {
71
+ u.Path = "/health"
72
+ } else {
73
+ u.Path = strings.TrimSuffix(u.Path, "/") + "/health"
74
+ }
75
+ }
76
+ client := &http.Client{Timeout: timeout}
77
+ req, _ := http.NewRequest(http.MethodGet, u.String(), nil)
78
+ resp, err := client.Do(req)
79
+ if err != nil {
80
+ return nil, err
81
+ }
82
+ defer resp.Body.Close()
83
+
84
+ var payload struct {
85
+ Status string `json:"status"`
86
+ PeerID string `json:"peerId"`
87
+ Multiaddrs []string `json:"multiaddrs"`
88
+ }
89
+ if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
90
+ return nil, err
91
+ }
92
+ if payload.Status != "ok" {
93
+ return nil, errors.New("health not ok")
94
+ }
95
+ addrs := make([]string, 0, len(payload.Multiaddrs))
96
+ for _, s := range payload.Multiaddrs {
97
+ // sanity check
98
+ if strings.Contains(s, "/p2p/") && (strings.Contains(s, "/ip4/") || strings.Contains(s, "/ip6/")) {
99
+ addrs = append(addrs, s)
100
+ }
101
+ }
102
+ return addrs, nil
103
+}