improve ui
Kim committed
Oct 21, 2025 at 14:37 UTC
bdecbe9a577686e2981afd89bae9adfd558fbc4e
7 files changed
+401
-220
cmd/example_client/main.go
+18
-55
@@ -5,11 +5,9 @@ import (
5
"crypto/rand"
6
"fmt"
7
"net"
8
- "net/http"
8
"os"
9
"os/signal"
10
"syscall"
12
- "text/template"
11
"time"
12
13
"github.com/gosuda/relaydns/relaydns"
@@ -28,6 +26,8 @@ var (
26
flagBootstraps []string
27
flagAddr string
28
flagClientName string
29
+ flagProtocol string
30
+ flagTopic string
31
)
32
33
func init() {
@@ -36,6 +36,8 @@ func init() {
36
flags.StringSliceVar(&flagBootstraps, "bootstrap", nil, "multiaddrs with /p2p/ (supports /dnsaddr/ that resolves to /p2p/)")
37
flags.StringVar(&flagAddr, "addr", ":8081", "local backend HTTP listen address")
38
flags.StringVar(&flagClientName, "name", "", "backend display name shown on server UI")
39
+ flags.StringVar(&flagProtocol, "protocol", "/relaydns/http/1.0", "libp2p protocol id for streams (must match server)")
40
+ flags.StringVar(&flagTopic, "topic", "relaydns.backends", "pubsub topic for backend adverts")
41
}
42
43
func main() {
@@ -57,49 +59,32 @@ func runClient(cmd *cobra.Command, args []string) error {
59
}
60
61
// 1) HTTP backend
62
+ var clientRef *relaydns.RelayClient
63
ln, err := net.Listen("tcp", flagAddr)
64
if err != nil {
65
log.Fatal().Err(err).Msg("failed to listen")
66
}
67
log.Info().Msgf("[client] local backend http listening on %s", ln.Addr().String())
68
66
- go func() {
67
- mux := http.NewServeMux()
68
- mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
69
- data := struct {
70
- Now string
71
- Host string
72
- Addr string
73
- }{
74
- Now: time.Now().Format(time.RFC1123),
75
- Host: r.Host,
76
- Addr: flagAddr,
77
- }
78
- _ = pageTmpl.Execute(w, data)
79
- })
80
- mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
81
- w.WriteHeader(http.StatusOK)
82
- _, _ = w.Write([]byte("ok"))
83
- })
84
-
85
- log.Info().Msgf("[client] local backend http %s", flagAddr)
86
- if err := http.Serve(ln, mux); err != nil {
87
- log.Error().Err(err).Msg("[client] http backend error")
88
- cancel()
69
+ // Serve local backend view in a goroutine
70
+ go serveClientHTTP(ctx, ln, flagClientName, func() string {
71
+ if clientRef == nil {
72
+ return "Starting..."
73
}
90
- }()
74
+ return clientRef.ServerStatus()
75
+ }, cancel)
76
77
// 2) libp2p host
78
client, err := relaydns.NewClient(ctx, relaydns.ClientConfig{
94
- Protocol: "/relaydns/http/1.0",
95
- Topic: "relaydns.backends",
96
- AdvertiseEvery: 3 * time.Second,
97
- Name: flagClientName,
98
- TargetTCP: addrToTarget(flagAddr),
79
+ Protocol: flagProtocol,
80
+ Topic: flagTopic,
81
+ Advertise: 3 * time.Second,
82
+ Name: flagClientName,
83
+ TargetTCP: addrToTarget(flagAddr),
84
85
ServerURL: flagServerURL,
86
Bootstraps: flagBootstraps,
102
- HTTPTimeout: 3 * time.Second,
87
+ HTTPTimeout: 5 * time.Second,
88
PreferQUIC: true,
89
PreferLocal: true,
90
})
@@ -109,6 +94,7 @@ func runClient(cmd *cobra.Command, args []string) error {
94
if err := client.Start(ctx); err != nil {
95
return fmt.Errorf("start client: %w", err)
96
}
97
+ clientRef = client
98
defer client.Close()
99
100
// wait for termination
@@ -126,26 +112,3 @@ func addrToTarget(listen string) string {
112
}
113
return listen
114
}
129
-
130
-var pageTmpl = template.Must(template.New("index").Parse(`<!DOCTYPE html>
131
-<html lang="en">
132
-<head>
133
- <meta charset="UTF-8">
134
- <title>RelayDNS Backend</title>
135
- <style>
136
- body { font-family: sans-serif; background: #f9f9f9; padding: 40px; }
137
- h1 { color: #333; }
138
- footer { margin-top: 40px; color: #666; font-size: 0.9em; }
139
- .card { background: white; border-radius: 12px; padding: 24px; box-shadow: 0 2px 6px rgba(0,0,0,0.1); }
140
- </style>
141
-</head>
142
-<body>
143
- <div class="card">
144
- <h1>🚀 RelayDNS Backend</h1>
145
- <p>This page is served from the backend node.</p>
146
- <p>Current time: <b>{{.Now}}</b></p>
147
- <p>Hostname: <b>{{.Host}}</b></p>
148
- </div>
149
- <footer>relaydns demo client — served locally at {{.Addr}}</footer>
150
-</body>
151
-</html>`))
cmd/example_client/view.go
new
+77
@@ -0,0 +1,77 @@
1
+package main
2
+
3
+import (
4
+ "context"
5
+ "html/template"
6
+ "net"
7
+ "net/http"
8
+ "time"
9
+
10
+ "github.com/rs/zerolog/log"
11
+)
12
+
13
+// serveClientHTTP serves the simple local backend UI and health endpoint.
14
+// getStatus should return a short string like "Connected" or "Connecting...".
15
+func serveClientHTTP(ctx context.Context, ln net.Listener, name string, getStatus func() string, cancel context.CancelFunc) {
16
+ mux := http.NewServeMux()
17
+ mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
18
+ status := getStatus()
19
+ statusClass := "disconnected"
20
+ if status == "Connected" {
21
+ statusClass = "connected"
22
+ }
23
+ data := struct {
24
+ Now string
25
+ Name string
26
+ Addr string
27
+ Status string
28
+ StatusClass string
29
+ }{
30
+ Now: time.Now().Format(time.RFC1123),
31
+ Name: name,
32
+ Addr: ln.Addr().String(),
33
+ Status: status,
34
+ StatusClass: statusClass,
35
+ }
36
+ _ = clientPage.Execute(w, data)
37
+ })
38
+ mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
39
+ w.WriteHeader(http.StatusOK)
40
+ _, _ = w.Write([]byte("ok"))
41
+ })
42
+
43
+ log.Info().Msgf("[client] local backend http %s", ln.Addr().String())
44
+ if err := http.Serve(ln, mux); err != nil {
45
+ log.Error().Err(err).Msg("[client] http backend error")
46
+ cancel()
47
+ }
48
+}
49
+
50
+var clientPage = template.Must(template.New("index").Parse(`<!DOCTYPE html>
51
+<html lang="en">
52
+<head>
53
+ <meta charset="UTF-8">
54
+ <title>RelayDNS Backend</title>
55
+ <style>
56
+ body { font-family: sans-serif; background: #f9f9f9; padding: 40px; }
57
+ h1 { color: #333; }
58
+ footer { margin-top: 40px; color: #666; font-size: 0.9em; }
59
+ .card { background: white; border-radius: 12px; padding: 24px; box-shadow: 0 2px 6px rgba(0,0,0,0.1); }
60
+ .stat { display:inline-flex; align-items:center; gap:8px; padding:6px 10px; border-radius:999px; font-weight:700; font-size:14px }
61
+ .stat.connected { background:#ecfdf5; color:#065f46 }
62
+ .stat.disconnected { background:#fee2e2; color:#b91c1c }
63
+ .stat .dot { width:8px; height:8px; border-radius:999px; background:#10b981; display:inline-block }
64
+ .stat.disconnected .dot { background:#ef4444 }
65
+ </style>
66
+ </head>
67
+<body>
68
+ <div class="card">
69
+ <h1>🚀 RelayDNS Backend</h1>
70
+ <p>This page is served from the backend node.</p>
71
+ <p>Current time: <b>{{.Now}}</b></p>
72
+ <p>Name: <b>{{.Name}}</b></p>
73
+ <p>Server Status: <span class="stat {{.StatusClass}}"><span class="dot"></span>{{.Status}}</span></p>
74
+ </div>
75
+ <footer>relaydns demo client — served locally at {{.Addr}}</footer>
76
+</body>
77
+</html>`))
cmd/server/main.go
+6
-152
@@ -2,13 +2,8 @@ package main
2
3
import (
4
"context"
5
- "encoding/json"
6
- "fmt"
7
- "html/template"
8
- "net/http"
5
"os"
6
"os/signal"
11
- "strings"
7
"syscall"
8
"time"
9
@@ -27,6 +22,8 @@ var (
22
flagBootstraps []string
23
24
httpAddr string // unified admin + HTTP proxy (e.g. :8080)
25
+ protocol string
26
+ topic string
27
)
28
29
func init() {
@@ -34,6 +31,8 @@ func init() {
31
flags.StringSliceVar(&flagBootstraps, "bootstrap", nil, "multiaddrs with /p2p/ (supports /dnsaddr/ that resolves to /p2p/)")
32
33
flags.StringVar(&httpAddr, "http", ":8080", "Unified admin UI and HTTP proxy listen address")
34
+ flags.StringVar(&protocol, "protocol", "/relaydns/http/1.0", "libp2p protocol id for streams (must match clients)")
35
+ flags.StringVar(&topic, "topic", "relaydns.backends", "pubsub topic for backend adverts")
36
}
37
38
func main() {
@@ -53,94 +52,13 @@ func runServer(cmd *cobra.Command, args []string) error {
52
}
53
relaydns.ConnectBootstraps(ctx, h, flagBootstraps)
54
56
- d, err := relaydns.NewDirector(ctx, h, "/relaydns/http/1.0", "relaydns.backends")
55
+ d, err := relaydns.NewDirector(ctx, h, protocol, topic)
56
if err != nil {
57
return err
58
}
59
60
// Admin UI + per-peer HTTP proxy served here
62
- go func() {
63
- if httpAddr == "" {
64
- return
65
- }
66
- mux := http.NewServeMux()
67
- // Index page
68
- mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
69
- if r.URL.Path != "/" {
70
- http.NotFound(w, r)
71
- return
72
- }
73
- type row struct {
74
- Peer string
75
- Name string
76
- DNS string
77
- LastSeen string
78
- Link string
79
- }
80
- type page struct {
81
- NodeID string
82
- Addrs []string
83
- Rows []row
84
- }
85
- rows := make([]row, 0)
86
- for _, v := range d.Hosts() {
87
- rows = append(rows, row{
88
- Peer: v.Info.Peer,
89
- Name: v.Info.Name,
90
- DNS: v.Info.DNS,
91
- LastSeen: time.Since(v.LastSeen).Round(time.Second).String() + " ago",
92
- Link: "/peer/" + v.Info.Peer + "/",
93
- })
94
- }
95
- w.Header().Set("Content-Type", "text/html; charset=utf-8")
96
- log.Debug().Int("clients", len(rows)).Msg("render admin index")
97
- addrs := make([]string, 0)
98
- for _, a := range h.Addrs() {
99
- addrs = append(addrs, fmt.Sprintf("%s/p2p/%s", a.String(), h.ID().String()))
100
- }
101
- _ = adminIndexTmpl.Execute(w, page{NodeID: h.ID().String(), Addrs: addrs, Rows: rows})
102
- })
103
- // Per-peer proxy
104
- mux.HandleFunc("/peer/", func(w http.ResponseWriter, r *http.Request) {
105
- p := strings.TrimPrefix(r.URL.Path, "/peer/")
106
- parts := strings.SplitN(p, "/", 2)
107
- if len(parts) == 0 || parts[0] == "" {
108
- http.Error(w, "missing peer id", http.StatusBadRequest)
109
- return
110
- }
111
- peerID := parts[0]
112
- pathSuffix := "/"
113
- if len(parts) == 2 {
114
- pathSuffix = "/" + parts[1]
115
- }
116
- d.ProxyHTTP(w, r, peerID, pathSuffix)
117
- })
118
- // JSON hosts
119
- mux.HandleFunc("/hosts", func(w http.ResponseWriter, r *http.Request) {
120
- _ = json.NewEncoder(w).Encode(d.Hosts())
121
- })
122
- // No override endpoint; selection is explicit per-peer via /peer/{id}
123
- // Health
124
- mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
125
- type info struct {
126
- Status string `json:"status"`
127
- Addrs []string `json:"multiaddrs"`
128
- }
129
- list := make([]string, 0)
130
- for _, a := range h.Addrs() {
131
- list = append(list, fmt.Sprintf("%s/p2p/%s", a.String(), h.ID().String()))
132
- }
133
- resp := info{Status: "ok", Addrs: list}
134
- w.Header().Set("Content-Type", "application/json")
135
- _ = json.NewEncoder(w).Encode(resp)
136
- })
137
-
138
- log.Info().Msgf("[server] http (admin+proxy): %s", httpAddr)
139
- if err := http.ListenAndServe(httpAddr, mux); err != nil {
140
- log.Error().Err(err).Msg("[server] http error")
141
- cancel()
142
- }
143
- }()
61
+ go serveHTTP(ctx, httpAddr, d, h, cancel)
62
63
// graceful shutdown
64
sig := make(chan os.Signal, 1)
@@ -150,67 +68,3 @@ func runServer(cmd *cobra.Command, args []string) error {
68
time.Sleep(300 * time.Millisecond)
69
return nil
70
}
153
-
154
-var adminIndexTmpl = template.Must(template.New("admin-index").Parse(`<!doctype html>
155
-<html lang="ko">
156
-<head>
157
- <meta charset="utf-8"/>
158
- <meta name="viewport" content="width=device-width, initial-scale=1" />
159
- <title>RelayDNS — Admin</title>
160
- <style>
161
- * { box-sizing: border-box }
162
- body {
163
- margin: 0;
164
- background: #f6f7fb;
165
- color: #111827;
166
- font-family: system-ui, Segoe UI, Roboto, Helvetica, Arial, sans-serif;
167
- font-size: 16px;
168
- line-height: 1.5;
169
- }
170
- .wrap { max-width: 960px; margin: 0 auto; padding: 28px 18px }
171
- header { display:flex; align-items:center; justify-content:space-between; padding: 14px 18px; background:#ffffff; border:1px solid #e5e7eb; border-radius: 10px }
172
- .brand { font-weight: 700; font-size: 20px }
173
- .status { color:#059669; font-weight:600 }
174
- main { margin-top: 18px; display:block }
175
- .box { background:#ffffff; border:1px solid #e5e7eb; border-radius:10px; padding:16px; margin-bottom:12px }
176
- .mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 14px; color:#374151; word-break: break-all }
177
- .title { font-weight:700; margin: 0 0 8px 0; font-size: 18px; color:#374151 }
178
- .muted { color:#6b7280; font-size: 14px }
179
- .btn { display:inline-block; background:#2563eb; color:#fff; text-decoration:none; border-radius:8px; padding:10px 14px; font-weight:700; margin-top: 6px }
180
- </style>
181
- </head>
182
-<body>
183
- <div class="wrap">
184
- <header>
185
- <div class="brand">RelayDNS Admin</div>
186
- <div class="status">Active</div>
187
- </header>
188
- <main>
189
- <section class="box">
190
- <div class="title">Server</div>
191
- <div class="mono">Peer ID: {{.NodeID}}</div>
192
- {{if .Addrs}}
193
- <div class="muted" style="margin-top:6px">Multiaddrs</div>
194
- <div class="mono">{{range .Addrs}}{{.}}<br/>{{end}}</div>
195
- {{end}}
196
- <div class="muted" style="margin-top:6px">Known clients: {{len .Rows}}</div>
197
- </section>
198
- {{range .Rows}}
199
- <section class="box">
200
- <div class="title">{{if .Name}}{{.Name}}{{else}}(unnamed){{end}}</div>
201
- {{if .DNS}}<div class="muted">DNS: <span class="mono">{{.DNS}}</span></div>{{end}}
202
- <div class="muted">Peer</div>
203
- <div class="mono">{{.Peer}}</div>
204
- <div class="muted" style="margin-top:6px">Last seen: {{.LastSeen}}</div>
205
- <a class="btn" href="{{.Link}}">Open</a>
206
- </section>
207
- {{else}}
208
- <section class="box">
209
- <div class="title">No clients discovered</div>
210
- <div class="muted">Start a client and ensure bootstraps are configured.</div>
211
- </section>
212
- {{end}}
213
- </main>
214
- </div>
215
-</body>
216
-</html>`))
cmd/server/view.go
new
+194
@@ -0,0 +1,194 @@
1
+package main
2
+
3
+import (
4
+ "context"
5
+ "encoding/json"
6
+ "fmt"
7
+ "html/template"
8
+ "net/http"
9
+ "strings"
10
+ "time"
11
+
12
+ "github.com/gosuda/relaydns/relaydns"
13
+ "github.com/libp2p/go-libp2p/core/host"
14
+ "github.com/rs/zerolog/log"
15
+)
16
+
17
+// serveHTTP builds the HTTP mux and starts serving admin UI + per-peer proxy.
18
+func serveHTTP(ctx context.Context, addr string, d *relaydns.Director, h host.Host, cancel context.CancelFunc) {
19
+ if addr == "" {
20
+ return
21
+ }
22
+ mux := http.NewServeMux()
23
+
24
+ // Index page
25
+ mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
26
+ if r.URL.Path != "/" {
27
+ http.NotFound(w, r)
28
+ return
29
+ }
30
+ rows := make([]row, 0)
31
+ for _, v := range d.Hosts() {
32
+ ttl := ""
33
+ if v.Info.TTL > 0 {
34
+ ttl = fmt.Sprintf("%ds", v.Info.TTL)
35
+ }
36
+ rows = append(rows, row{
37
+ Peer: v.Info.Peer,
38
+ Name: v.Info.Name,
39
+ DNS: v.Info.DNS,
40
+ LastSeen: time.Since(v.LastSeen).Round(time.Second).String() + " ago",
41
+ Link: "/peer/" + v.Info.Peer + "/",
42
+ TTL: ttl,
43
+ Connected: v.Connected,
44
+ })
45
+ }
46
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
47
+ log.Debug().Int("clients", len(rows)).Msg("render admin index")
48
+ _ = adminIndexTmpl.Execute(w, page{
49
+ NodeID: h.ID().String(),
50
+ Addrs: buildAddrs(h),
51
+ Rows: rows,
52
+ })
53
+ })
54
+
55
+ // Per-peer proxy
56
+ mux.HandleFunc("/peer/", func(w http.ResponseWriter, r *http.Request) {
57
+ p := strings.TrimPrefix(r.URL.Path, "/peer/")
58
+ parts := strings.SplitN(p, "/", 2)
59
+ if len(parts) == 0 || parts[0] == "" {
60
+ http.Error(w, "missing peer id", http.StatusBadRequest)
61
+ return
62
+ }
63
+ peerID := parts[0]
64
+ pathSuffix := "/"
65
+ if len(parts) == 2 {
66
+ pathSuffix = "/" + parts[1]
67
+ }
68
+ d.ProxyHTTP(w, r, peerID, pathSuffix)
69
+ })
70
+
71
+ // JSON hosts
72
+ mux.HandleFunc("/hosts", func(w http.ResponseWriter, r *http.Request) {
73
+ _ = json.NewEncoder(w).Encode(d.Hosts())
74
+ })
75
+
76
+ // Health
77
+ mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
78
+ type info struct {
79
+ Status string `json:"status"`
80
+ Addrs []string `json:"multiaddrs"`
81
+ }
82
+ resp := info{Status: "ok", Addrs: buildAddrs(h)}
83
+ w.Header().Set("Content-Type", "application/json")
84
+ _ = json.NewEncoder(w).Encode(resp)
85
+ })
86
+
87
+ log.Info().Msgf("[server] http (admin+proxy): %s", addr)
88
+ if err := http.ListenAndServe(addr, mux); err != nil {
89
+ log.Error().Err(err).Msg("[server] http error")
90
+ cancel()
91
+ }
92
+}
93
+
94
+// view model types used by template rendering
95
+type row struct {
96
+ Peer string
97
+ Name string
98
+ DNS string
99
+ LastSeen string
100
+ Link string
101
+ TTL string
102
+ Connected bool
103
+}
104
+
105
+type page struct {
106
+ NodeID string
107
+ Addrs []string
108
+ Rows []row
109
+}
110
+
111
+func buildAddrs(h host.Host) []string {
112
+ out := make([]string, 0)
113
+ for _, a := range h.Addrs() {
114
+ out = append(out, fmt.Sprintf("%s/p2p/%s", a.String(), h.ID().String()))
115
+ }
116
+ return out
117
+}
118
+
119
+var adminIndexTmpl = template.Must(template.New("admin-index").Parse(`<!doctype html>
120
+<html lang="ko">
121
+<head>
122
+ <meta charset="utf-8"/>
123
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
124
+ <title>RelayDNS — Admin</title>
125
+ <style>
126
+ * { box-sizing: border-box }
127
+ body {
128
+ margin: 0;
129
+ background: #f6f7fb;
130
+ color: #111827;
131
+ font-family: sans-serif;
132
+ font-size: 16px;
133
+ line-height: 1.5;
134
+ }
135
+ .wrap { max-width: 960px; margin: 0 auto; padding: 28px 18px }
136
+ header { display:flex; align-items:center; justify-content:space-between; padding: 14px 18px; background:#ffffff; border:1px solid #e5e7eb; border-radius: 10px }
137
+ .brand { font-weight: 700; font-size: 20px }
138
+ .status { color:#059669; font-weight:600 }
139
+ main { margin-top: 18px; display:block }
140
+ .box { background:#ffffff; border:1px solid #e5e7eb; border-radius:10px; padding:16px; margin-bottom:12px }
141
+ .mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 14px; color:#374151; word-break: break-all }
142
+ .title { font-weight:700; margin: 0 0 8px 0; font-size: 18px; color:#374151 }
143
+ .muted { color:#6b7280; font-size: 14px }
144
+ .btn { display:inline-block; background:#2563eb; color:#fff; text-decoration:none; border-radius:8px; padding:10px 14px; font-weight:700; margin-top: 6px }
145
+ .stat { display:inline-flex; align-items:center; gap:8px; padding:6px 10px; border-radius:999px; font-weight:700; font-size:14px }
146
+ .stat.connected { background:#ecfdf5; color:#065f46 }
147
+ .stat.disconnected { background:#fee2e2; color:#b91c1c }
148
+ .stat .dot { width:8px; height:8px; border-radius:999px; background:#10b981; display:inline-block }
149
+ .stat.disconnected .dot { background:#ef4444 }
150
+ .head { display:flex; align-items:center; justify-content:space-between; gap:12px }
151
+ </style>
152
+ </head>
153
+<body>
154
+ <div class="wrap">
155
+ <header>
156
+ <div class="brand">RelayDNS Admin</div>
157
+ <div class="status">Active</div>
158
+ </header>
159
+ <main>
160
+ <section class="box">
161
+ <div class="title">Server</div>
162
+ <div class="mono">Peer ID: {{.NodeID}}</div>
163
+ {{if .Addrs}}
164
+ <div class="muted" style="margin-top:6px">Multiaddrs</div>
165
+ <div class="mono">{{range .Addrs}}{{.}}<br/>{{end}}</div>
166
+ {{end}}
167
+ <div class="muted" style="margin-top:6px">Known clients: {{len .Rows}}</div>
168
+ </section>
169
+ {{range .Rows}}
170
+ <section class="box">
171
+ <div class="head">
172
+ <div class="title">{{if .Name}}{{.Name}}{{else}}(unnamed){{end}}</div>
173
+ {{if .Connected}}
174
+ <span class="stat connected"><span class="dot"></span>Connected</span>
175
+ {{else}}
176
+ <span class="stat disconnected"><span class="dot"></span>Disconnected</span>
177
+ {{end}}
178
+ </div>
179
+ {{if .DNS}}<div class="muted">DNS: <span class="mono">{{.DNS}}</span></div>{{end}}
180
+ <div class="muted">Peer</div>
181
+ <div class="mono">{{.Peer}}</div>
182
+ <div class="muted" style="margin-top:6px">Last seen: {{.LastSeen}}{{if .TTL}} · TTL: {{.TTL}}{{end}}</div>
183
+ <a class="btn" href="{{.Link}}">Open</a>
184
+ </section>
185
+ {{else}}
186
+ <section class="box">
187
+ <div class="title">No clients discovered</div>
188
+ <div class="muted">Start a client and ensure bootstraps are configured.</div>
189
+ </section>
190
+ {{end}}
191
+ </main>
192
+ </div>
193
+</body>
194
+</html>`))
relaydns/client.go
+70
-4
@@ -33,7 +33,11 @@ type ClientConfig struct {
33
// pubsub topic for backend adverts (e.g. "relaydns.backends")
34
Topic string
35
// advertise interval
36
- AdvertiseEvery time.Duration
36
+ Advertise time.Duration
37
+ // advertise TTL (how long server should keep this entry alive)
38
+ AdvertiseTTL time.Duration
39
+ // how often to refresh server health/bootstraps (if ServerURL set)
40
+ RefreshBootstrapsEvery time.Duration
41
// optional metadata
42
Name string
43
DNS string
@@ -54,17 +58,26 @@ type RelayClient struct {
58
t *pubsub.Topic
59
wg sync.WaitGroup
60
stop context.CancelFunc
61
+
62
+ statusMu sync.RWMutex
63
+ serverHealthy bool
64
}
65
66
// NewClient constructs a client with defaults applied and an initialized libp2p host.
67
// It does not start networking. Call Start(ctx) to begin handlers, pubsub, and advertising.
68
func NewClient(ctx context.Context, cfg ClientConfig) (*RelayClient, error) {
62
- if cfg.AdvertiseEvery <= 0 {
63
- cfg.AdvertiseEvery = 5 * time.Second
69
+ if cfg.Advertise <= 0 {
70
+ cfg.Advertise = 5 * time.Second
71
+ }
72
+ if cfg.AdvertiseTTL <= 0 {
73
+ cfg.AdvertiseTTL = 10 * cfg.Advertise
74
}
75
if cfg.HTTPTimeout <= 0 {
76
cfg.HTTPTimeout = 3 * time.Second
77
}
78
+ if cfg.RefreshBootstrapsEvery <= 0 {
79
+ cfg.RefreshBootstrapsEvery = 20 * time.Second
80
+ }
81
if cfg.Protocol == "" {
82
cfg.Protocol = "/relaydns/http/1.0"
83
}
@@ -96,8 +109,10 @@ func (b *RelayClient) Start(ctx context.Context) error {
109
}
110
if b.cfg.ServerURL != "" {
111
if addrs, err := fetchMultiaddrsFromHealth(b.cfg.ServerURL, b.cfg.HTTPTimeout); err != nil {
112
+ b.setServerHealthy(false)
113
log.Warn().Err(err).Msgf("relaydns: fetch /health from %s failed", b.cfg.ServerURL)
114
} else {
115
+ b.setServerHealthy(true)
116
sortMultiaddrs(addrs, b.cfg.PreferQUIC, b.cfg.PreferLocal)
117
boot = append(boot, addrs...)
118
}
@@ -147,7 +162,7 @@ func (b *RelayClient) Start(ctx context.Context) error {
162
b.wg.Add(1)
163
go func() {
164
defer b.wg.Done()
150
- ticker := time.NewTicker(b.cfg.AdvertiseEvery)
165
+ ticker := time.NewTicker(b.cfg.Advertise)
166
defer ticker.Stop()
167
for {
168
select {
@@ -167,6 +182,7 @@ func (b *RelayClient) Start(ctx context.Context) error {
182
Ready: true,
183
Load: 0.0,
184
TS: time.Now().UTC(),
185
+ TTL: int(b.cfg.AdvertiseTTL.Seconds()),
186
}
187
payload, _ := json.Marshal(ad)
188
_ = b.t.Publish(advCtx, payload)
@@ -174,6 +190,35 @@ func (b *RelayClient) Start(ctx context.Context) error {
190
}
191
}()
192
193
+ // background: periodically re-fetch bootstraps from server and reconnect
194
+ if b.cfg.ServerURL != "" {
195
+ b.wg.Add(1)
196
+ go func() {
197
+ defer b.wg.Done()
198
+ t := time.NewTicker(b.cfg.RefreshBootstrapsEvery)
199
+ defer t.Stop()
200
+ for {
201
+ select {
202
+ case <-advCtx.Done():
203
+ return
204
+ case <-t.C:
205
+ addrs, err := fetchMultiaddrsFromHealth(b.cfg.ServerURL, b.cfg.HTTPTimeout)
206
+ if err != nil {
207
+ b.setServerHealthy(false)
208
+ log.Warn().Err(err).Msgf("refresh /health from %s failed", b.cfg.ServerURL)
209
+ continue
210
+ }
211
+ b.setServerHealthy(true)
212
+ sortMultiaddrs(addrs, b.cfg.PreferQUIC, b.cfg.PreferLocal)
213
+ addrs = uniq(addrs)
214
+ if len(addrs) > 0 {
215
+ ConnectBootstraps(ctx, b.h, addrs)
216
+ }
217
+ }
218
+ }
219
+ }()
220
+ }
221
+
222
if addrs := b.Host().Addrs(); len(addrs) > 0 {
223
for _, a := range addrs {
224
log.Info().Msgf("[client] host addr: %s/p2p/%s", a.String(), b.Host().ID().String())
@@ -198,6 +243,27 @@ func (b *RelayClient) Close() error {
243
return nil
244
}
245
246
+func (b *RelayClient) setServerHealthy(ok bool) {
247
+ b.statusMu.Lock()
248
+ b.serverHealthy = ok
249
+ b.statusMu.Unlock()
250
+}
251
+
252
+// ServerStatus returns a human-friendly status regarding connection to ServerURL.
253
+// If no ServerURL is configured, returns "N/A".
254
+func (b *RelayClient) ServerStatus() string {
255
+ if b.cfg.ServerURL == "" {
256
+ return "N/A"
257
+ }
258
+ b.statusMu.RLock()
259
+ ok := b.serverHealthy
260
+ b.statusMu.RUnlock()
261
+ if ok {
262
+ return "Connected"
263
+ }
264
+ return "Connecting..."
265
+}
266
+
267
func fetchMultiaddrsFromHealth(base string, timeout time.Duration) ([]string, error) {
268
u, err := url.Parse(base)
269
if err != nil {
relaydns/director.go
+28
-3
@@ -7,6 +7,7 @@ import (
7
"io"
8
"net/http"
9
"net/url"
10
+ "sort"
11
"sync"
12
"time"
13
@@ -27,6 +28,7 @@ type Director struct {
28
storeMu sync.Mutex
29
store map[string]HostEntry
30
ttl time.Duration
31
+ deadTTL time.Duration
32
}
33
34
func NewDirector(ctx context.Context, h host.Host, protocol, topic string) (*Director, error) {
@@ -49,7 +51,8 @@ func NewDirector(ctx context.Context, h host.Host, protocol, topic string) (*Dir
51
topicName: topic,
52
sub: sub,
53
store: map[string]HostEntry{},
52
- ttl: 45 * time.Second,
54
+ ttl: 15 * time.Second,
55
+ deadTTL: 10 * time.Minute,
56
}
57
go d.collect()
58
go d.gc()
@@ -89,7 +92,7 @@ func (d *Director) collect() {
92
now := time.Now()
93
d.storeMu.Lock()
94
_, existed := d.store[ad.Peer]
92
- d.store[ad.Peer] = HostEntry{Info: ad, AddrInfo: ai, LastSeen: now}
95
+ d.store[ad.Peer] = HostEntry{Info: ad, AddrInfo: ai, LastSeen: now, Connected: true}
96
// refresh picker snapshot
97
snap := make([]HostEntry, 0, len(d.store))
98
for _, v := range d.store {
@@ -117,7 +120,25 @@ func (d *Director) gc() {
120
removed := make([]HostEntry, 0)
121
d.storeMu.Lock()
122
for k, v := range d.store {
120
- if now.Sub(v.LastSeen) > d.ttl {
123
+ // Prefer client-provided TTL if present; fallback to default d.ttl
124
+ ttl := d.ttl
125
+ if v.Info.TTL > 0 {
126
+ ttl = time.Duration(v.Info.TTL) * time.Second
127
+ }
128
+ // mark disconnected after ttl
129
+ if now.Sub(v.LastSeen) > ttl && v.Connected {
130
+ v.Connected = false
131
+ d.store[k] = v
132
+ }
133
+ // remove only after extended dead TTL
134
+ deadAfter := d.deadTTL
135
+ if v.Info.TTL > 0 {
136
+ da := time.Duration(v.Info.TTL) * time.Second * 5
137
+ if da > deadAfter {
138
+ deadAfter = da
139
+ }
140
+ }
141
+ if now.Sub(v.LastSeen) > deadAfter {
142
removed = append(removed, v)
143
delete(d.store, k)
144
}
@@ -143,6 +164,10 @@ func (d *Director) Hosts() []HostEntry {
164
for _, v := range d.store {
165
list = append(list, v)
166
}
167
+ // Sort by last-seen (most recent first)
168
+ sort.SliceStable(list, func(i, j int) bool {
169
+ return list[i].LastSeen.After(list[j].LastSeen)
170
+ })
171
return list
172
}
173
relaydns/types.go
+8
-6
@@ -1,10 +1,10 @@
1
package relaydns
2
3
import (
4
- "time"
4
+ "time"
5
6
- "github.com/libp2p/go-libp2p/core/peer"
7
- "github.com/libp2p/go-libp2p/core/protocol"
6
+ "github.com/libp2p/go-libp2p/core/peer"
7
+ "github.com/libp2p/go-libp2p/core/protocol"
8
)
9
10
type Advertise struct {
@@ -15,12 +15,14 @@ type Advertise struct {
15
Ready bool `json:"ready"`
16
Load float64 `json:"load"`
17
TS time.Time `json:"ts"`
18
+ TTL int `json:"ttl,omitempty"` // seconds
19
}
20
21
type HostEntry struct {
21
- Info Advertise
22
- AddrInfo *peer.AddrInfo
23
- LastSeen time.Time
22
+ Info Advertise
23
+ AddrInfo *peer.AddrInfo
24
+ LastSeen time.Time
25
+ Connected bool
26
}
27
28
// Removed Picker: selection is explicit via /peer/{peerID}/...