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 }