director : add client select page
Kim committed
Oct 21, 2025 at 12:18 UTC
c7fb5966a6a69b5ce2c4f10a0755c0b41372a72c
1 file changed
+206
-44
relaydns/director.go
+206
-44
@@ -1,12 +1,16 @@
1
package relaydns
2
3
import (
4
+ "bufio"
5
"context"
6
"encoding/json"
7
"fmt"
8
+ "html/template"
9
"io"
10
"net"
11
"net/http"
12
+ "net/url"
13
+ "strings"
14
"sync"
15
"time"
16
@@ -88,14 +92,21 @@ func (d *Director) collect() {
92
if ai == nil {
93
continue
94
}
95
+ now := time.Now()
96
d.storeMu.Lock()
92
- d.store[ad.Peer] = HostEntry{Info: ad, AddrInfo: ai, LastSeen: time.Now()}
97
+ _, existed := d.store[ad.Peer]
98
+ d.store[ad.Peer] = HostEntry{Info: ad, AddrInfo: ai, LastSeen: now}
99
// refresh picker snapshot
100
snap := make([]HostEntry, 0, len(d.store))
101
for _, v := range d.store {
102
snap = append(snap, v)
103
}
104
d.storeMu.Unlock()
105
+ if existed {
106
+ log.Debug().Str("peer", ad.Peer).Str("name", ad.Name).Msg("director: updated client advert")
107
+ } else {
108
+ log.Info().Str("peer", ad.Peer).Str("name", ad.Name).Msg("director: added client")
109
+ }
110
d.pick.update(snap)
111
}
112
}
@@ -109,9 +120,11 @@ func (d *Director) gc() {
120
return
121
case <-t.C:
122
now := time.Now()
123
+ removed := make([]HostEntry, 0)
124
d.storeMu.Lock()
125
for k, v := range d.store {
126
if now.Sub(v.LastSeen) > d.ttl {
127
+ removed = append(removed, v)
128
delete(d.store, k)
129
}
130
}
@@ -120,6 +133,9 @@ func (d *Director) gc() {
133
snap = append(snap, v)
134
}
135
d.storeMu.Unlock()
136
+ for _, r := range removed {
137
+ log.Info().Str("peer", r.Info.Peer).Str("name", r.Info.Name).Dur("idle", now.Sub(r.LastSeen)).Msg("director: removed stale client")
138
+ }
139
d.pick.update(snap)
140
}
141
}
@@ -166,50 +182,196 @@ func (d *Director) handleConn(c net.Conn) {
182
183
func (d *Director) ServeHTTP(addr string) error {
184
mux := http.NewServeMux()
169
- mux.HandleFunc("/hosts", func(w http.ResponseWriter, r *http.Request) {
170
- d.storeMu.Lock()
171
- defer d.storeMu.Unlock()
172
- list := make([]HostEntry, 0, len(d.store))
173
- for _, v := range d.store {
174
- list = append(list, v)
185
+ mux.HandleFunc("/", d.handleIndex)
186
+ mux.HandleFunc("/peer/", d.handlePeerProxy)
187
+ mux.HandleFunc("/hosts", d.handleHosts)
188
+ mux.HandleFunc("/override", d.handleOverride)
189
+ mux.HandleFunc("/health", d.handleHealth)
190
+ log.Info().Msgf("director HTTP API on %s", addr)
191
+ return http.ListenAndServe(addr, mux)
192
+}
193
+
194
+var adminIndexTmpl = template.Must(template.New("admin-index").Parse(`<!doctype html>
195
+<html>
196
+<head>
197
+ <meta charset="utf-8"/>
198
+ <title>RelayDNS Admin</title>
199
+ <style>
200
+ body { font-family: system-ui, sans-serif; margin: 24px; }
201
+ h1 { margin: 0 0 16px 0; }
202
+ .card { border: 1px solid #ddd; border-radius: 10px; padding: 16px; margin: 12px 0; }
203
+ .row { display: flex; gap: 16px; align-items: center; flex-wrap: wrap; }
204
+ .mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
205
+ input[type=text] { padding: 6px 8px; border: 1px solid #ccc; border-radius: 6px; min-width: 260px; }
206
+ a.btn { text-decoration: none; background:#2d6cdf; color:white; padding:6px 10px; border-radius:6px; }
207
+ small { color:#666 }
208
+ </style>
209
+ <script>
210
+ function goToPeer(peerId){
211
+ const inp = document.getElementById('path-'+peerId);
212
+ const path = inp && inp.value ? ('/' + inp.value.replace(/^\/+/, '')) : '/';
213
+ window.location.href = '/peer/' + peerId + path;
214
+ return false;
215
+ }
216
+ </script>
217
+ </head>
218
+<body>
219
+ <h1>RelayDNS Admin</h1>
220
+ <p>Known clients: {{len .Rows}}</p>
221
+ {{range .Rows}}
222
+ <div class="card">
223
+ <div class="row">
224
+ <b>{{if .Name}}{{.Name}}{{else}}(unnamed){{end}}</b>
225
+ <span class="mono">{{.Peer}}</span>
226
+ {{if .DNS}}<span>DNS: <span class="mono">{{.DNS}}</span></span>{{end}}
227
+ <small>last seen: {{.LastSeen}}</small>
228
+ </div>
229
+ <div class="row" style="margin-top:8px;">
230
+ <a class="btn" href="{{.Link}}">Open</a>
231
+ <form onsubmit="return goToPeer('{{.Peer}}')">
232
+ <input id="path-{{.Peer}}" type="text" placeholder="optional path, e.g. api/health" />
233
+ <button class="btn" type="submit">Open Path</button>
234
+ </form>
235
+ </div>
236
+ </div>
237
+ {{else}}
238
+ <p>No clients discovered yet. Ensure backends are advertising and bootstraps are configured.</p>
239
+ {{end}}
240
+</body>
241
+</html>`))
242
+
243
+func (d *Director) handleIndex(w http.ResponseWriter, r *http.Request) {
244
+ type row struct {
245
+ Peer string
246
+ Name string
247
+ DNS string
248
+ LastSeen string
249
+ Link string
250
+ }
251
+ type page struct{ Rows []row }
252
+ d.storeMu.Lock()
253
+ rows := make([]row, 0, len(d.store))
254
+ for _, v := range d.store {
255
+ rows = append(rows, row{
256
+ Peer: v.Info.Peer,
257
+ Name: v.Info.Name,
258
+ DNS: v.Info.DNS,
259
+ LastSeen: time.Since(v.LastSeen).Round(time.Second).String() + " ago",
260
+ Link: "/peer/" + v.Info.Peer + "/",
261
+ })
262
+ }
263
+ d.storeMu.Unlock()
264
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
265
+ _ = adminIndexTmpl.Execute(w, page{Rows: rows})
266
+}
267
+
268
+func (d *Director) handlePeerProxy(w http.ResponseWriter, r *http.Request) {
269
+ p := strings.TrimPrefix(r.URL.Path, "/peer/")
270
+ parts := strings.SplitN(p, "/", 2)
271
+ if len(parts) == 0 || parts[0] == "" {
272
+ http.Error(w, "missing peer id", http.StatusBadRequest)
273
+ return
274
+ }
275
+ peerID := parts[0]
276
+ pathSuffix := "/"
277
+ if len(parts) == 2 {
278
+ pathSuffix = "/" + parts[1]
279
+ }
280
+
281
+ d.storeMu.Lock()
282
+ entry, ok := d.store[peerID]
283
+ d.storeMu.Unlock()
284
+ if !ok || entry.AddrInfo == nil {
285
+ http.Error(w, "peer not found", http.StatusNotFound)
286
+ return
287
+ }
288
+
289
+ if err := d.h.Connect(d.ctx, *entry.AddrInfo); err != nil {
290
+ log.Error().Err(err).Msgf("connect %s failed", entry.AddrInfo.ID)
291
+ http.Error(w, "upstream connect failed", http.StatusBadGateway)
292
+ return
293
+ }
294
+
295
+ s, err := d.h.NewStream(d.ctx, entry.AddrInfo.ID, protocolID(d.protocol))
296
+ if err != nil {
297
+ log.Error().Err(err).Msg("new stream")
298
+ http.Error(w, "open stream failed", http.StatusBadGateway)
299
+ return
300
+ }
301
+ defer s.Close()
302
+
303
+ outReq := r.Clone(d.ctx)
304
+ outReq.URL = &url.URL{Path: pathSuffix, RawQuery: r.URL.RawQuery}
305
+ outReq.RequestURI = ""
306
+ if err := outReq.Write(s); err != nil {
307
+ log.Error().Err(err).Msg("write upstream request")
308
+ http.Error(w, "write upstream failed", http.StatusBadGateway)
309
+ return
310
+ }
311
+
312
+ br := bufio.NewReader(s)
313
+ resp, err := http.ReadResponse(br, outReq)
314
+ if err != nil {
315
+ log.Error().Err(err).Msg("read upstream response")
316
+ http.Error(w, "bad upstream response", http.StatusBadGateway)
317
+ return
318
+ }
319
+ defer resp.Body.Close()
320
+
321
+ for k, vv := range resp.Header {
322
+ for _, v := range vv {
323
+ w.Header().Add(k, v)
324
}
176
- _ = json.NewEncoder(w).Encode(list)
177
- })
178
- mux.HandleFunc("/override", func(w http.ResponseWriter, r *http.Request) {
179
- switch r.Method {
180
- case "POST":
181
- peerID := r.URL.Query().Get("peer")
182
- dur := 30 * time.Second
183
- if s := r.URL.Query().Get("ttl"); s != "" {
184
- if v, err := time.ParseDuration(s); err == nil {
185
- dur = v
186
- }
325
+ }
326
+ w.WriteHeader(resp.StatusCode)
327
+ _, _ = io.Copy(w, resp.Body)
328
+}
329
+
330
+func (d *Director) handleHosts(w http.ResponseWriter, r *http.Request) {
331
+ d.storeMu.Lock()
332
+ defer d.storeMu.Unlock()
333
+ list := make([]HostEntry, 0, len(d.store))
334
+ for _, v := range d.store {
335
+ list = append(list, v)
336
+ }
337
+ _ = json.NewEncoder(w).Encode(list)
338
+}
339
+
340
+func (d *Director) handleOverride(w http.ResponseWriter, r *http.Request) {
341
+ switch r.Method {
342
+ case "POST":
343
+ peerID := r.URL.Query().Get("peer")
344
+ dur := 30 * time.Second
345
+ if s := r.URL.Query().Get("ttl"); s != "" {
346
+ if v, err := time.ParseDuration(s); err == nil {
347
+ dur = v
348
}
188
- d.pick.pin(peerID, dur)
189
- w.WriteHeader(204)
190
- case "DELETE":
191
- d.pick.unpin()
192
- w.WriteHeader(204)
193
- default:
194
- w.WriteHeader(405)
195
- }
196
- })
197
- mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
198
- type info struct {
199
- Status string `json:"status"`
200
- Addrs []string `json:"multiaddrs"`
349
}
202
- var list []string = make([]string, 0)
203
- for _, a := range d.h.Addrs() {
204
- list = append(list, fmt.Sprintf("%s/p2p/%s", a.String(), d.h.ID().String()))
205
- }
206
- resp := info{
207
- Status: "ok",
208
- Addrs: list,
209
- }
210
- w.Header().Set("Content-Type", "application/json")
211
- _ = json.NewEncoder(w).Encode(resp)
212
- })
213
- log.Info().Msgf("director HTTP API on %s", addr)
214
- return http.ListenAndServe(addr, mux)
350
+ d.pick.pin(peerID, dur)
351
+ log.Info().Str("peer", peerID).Dur("ttl", dur).Msg("director: pin override")
352
+ w.WriteHeader(204)
353
+ case "DELETE":
354
+ d.pick.unpin()
355
+ log.Info().Msg("director: unpin override")
356
+ w.WriteHeader(204)
357
+ default:
358
+ w.WriteHeader(405)
359
+ }
360
+}
361
+
362
+func (d *Director) handleHealth(w http.ResponseWriter, r *http.Request) {
363
+ type info struct {
364
+ Status string `json:"status"`
365
+ Addrs []string `json:"multiaddrs"`
366
+ }
367
+ var list []string = make([]string, 0)
368
+ for _, a := range d.h.Addrs() {
369
+ list = append(list, fmt.Sprintf("%s/p2p/%s", a.String(), d.h.ID().String()))
370
+ }
371
+ resp := info{
372
+ Status: "ok",
373
+ Addrs: list,
374
+ }
375
+ w.Header().Set("Content-Type", "application/json")
376
+ _ = json.NewEncoder(w).Encode(resp)
377
}