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}/...