cmd: add server and client example

Kim committed Oct 28, 2025 at 12:13 UTC 4c1130629f5a85896c02b72954bac1f9a4e1e738
4 files changed +498 -203
cmd/client/main.go new
+117
@@ -0,0 +1,117 @@
1 +package main
2 +
3 +import (
4 + "context"
5 + "fmt"
6 + "net/http"
7 + "os/signal"
8 + "syscall"
9 + "time"
10 +
11 + "github.com/rs/zerolog/log"
12 + "github.com/spf13/cobra"
13 +
14 + "github.com/gosuda/relaydns/sdk"
15 +)
16 +
17 +var (
18 + flagBootstraps []string
19 + flagName string
20 + flagALPNs []string
21 + flagAdminPort int
22 +)
23 +
24 +var rootCmd = &cobra.Command{
25 + Use: "relayclient",
26 + Short: "RelayDNS demo client that serves a simple HTTP backend over the relay",
27 + RunE: runClient,
28 +}
29 +
30 +func init() {
31 + flags := rootCmd.PersistentFlags()
32 + flags.StringArrayVar(&flagBootstraps, "bootstrap", []string{"ws://127.0.0.1:4017"}, "bootstrap websocket url (repeatable), e.g. ws://127.0.0.1:4017/relay")
33 + flags.StringVar(&flagName, "name", "demo", "lease name to display on server UI")
34 + flags.StringArrayVar(&flagALPNs, "alpn", []string{"h1"}, "ALPN identifier for this service")
35 + flags.IntVar(&flagAdminPort, "admin-port", 0, "optional admin UI port (0 to disable)")
36 +}
37 +
38 +func main() {
39 + if err := rootCmd.Execute(); err != nil {
40 + log.Fatal().Err(err).Msg("execute client")
41 + }
42 +}
43 +
44 +func runClient(cmd *cobra.Command, args []string) error {
45 + if len(flagBootstraps) == 0 {
46 + return fmt.Errorf("no bootstrap servers provided; use --bootstrap ws://host:port/relay")
47 + }
48 +
49 + // Ctrl-C / SIGTERM handling
50 + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
51 + defer stop()
52 +
53 + // Create credential for this client (in-memory)
54 + cred, err := sdk.NewCredential()
55 + if err != nil {
56 + return err
57 + }
58 +
59 + // Create client and connect to relay(s)
60 + client, err := sdk.NewClient(func(c *sdk.RDClientConfig) {
61 + c.BootstrapServers = flagBootstraps
62 + })
63 + if err != nil {
64 + return err
65 + }
66 + defer client.Close()
67 +
68 + // Register lease and obtain a net.Listener that accepts relayed connections
69 + listener, err := client.Listen(cred, flagName, flagALPNs)
70 + if err != nil {
71 + return err
72 + }
73 + defer listener.Close()
74 +
75 + // Simple HTTP backend on the relay listener
76 + mux := http.NewServeMux()
77 + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
78 + fmt.Fprintf(w, "ok: relaydns backend\n")
79 + fmt.Fprintf(w, "time: %s\n", time.Now().Format(time.RFC3339))
80 + fmt.Fprintf(w, "method: %s\n", r.Method)
81 + fmt.Fprintf(w, "path: %s\n", r.URL.Path)
82 + fmt.Fprintf(w, "client: %s\n", r.RemoteAddr)
83 + fmt.Fprintf(w, "server: %s\n", r.Host)
84 + })
85 +
86 + // Optional local admin UI
87 + var adminSrv *http.Server
88 + if flagAdminPort > 0 {
89 + adminSrv = serveClientHTTP(ctx, fmt.Sprintf(":%d", flagAdminPort), cred.ID(), flagName, flagALPNs, flagBootstraps, client.GetRelays, stop)
90 + }
91 +
92 + // Serve HTTP over relay listener
93 + srvErr := make(chan error, 1)
94 + go func() {
95 + log.Info().Msgf("[client] serving HTTP over relay; lease=%s id=%s", flagName, cred.ID())
96 + srvErr <- http.Serve(listener, mux)
97 + }()
98 +
99 + select {
100 + case <-ctx.Done():
101 + log.Info().Msg("[client] shutting down...")
102 + case err := <-srvErr:
103 + if err != nil {
104 + log.Error().Err(err).Msg("[client] http serve error")
105 + }
106 + }
107 +
108 + // Stop admin UI if started
109 + if adminSrv != nil {
110 + shutdownCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
111 + defer cancel()
112 + _ = adminSrv.Shutdown(shutdownCtx)
113 + }
114 +
115 + log.Info().Msg("[client] shutdown complete")
116 + return nil
117 +}
cmd/client/view.go new
+123
@@ -0,0 +1,123 @@
1 +package main
2 +
3 +import (
4 + "context"
5 + "encoding/json"
6 + "html/template"
7 + "net/http"
8 +
9 + "github.com/rs/zerolog/log"
10 +)
11 +
12 +type clientViewData struct {
13 + NodeID string
14 + Name string
15 + ALPNs []string
16 + Bootstraps []string
17 + Relays []string
18 +}
19 +
20 +// serveClientHTTP starts a tiny admin UI for the client.
21 +func serveClientHTTP(ctx context.Context, addr string, nodeID string, name string, alpns []string, bootstraps []string, getRelays func() []string, cancel context.CancelFunc) *http.Server {
22 + if addr == "" {
23 + addr = ":0"
24 + }
25 +
26 + mux := http.NewServeMux()
27 +
28 + // Index page
29 + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
30 + if r.URL.Path != "/" {
31 + http.NotFound(w, r)
32 + return
33 + }
34 + data := clientViewData{
35 + NodeID: nodeID,
36 + Name: name,
37 + ALPNs: alpns,
38 + Bootstraps: bootstraps,
39 + Relays: getRelays(),
40 + }
41 + w.Header().Set("Content-Type", "text/html; charset=utf-8")
42 + if err := clientTmpl.Execute(w, data); err != nil {
43 + log.Error().Err(err).Msg("[client] render admin index")
44 + }
45 + })
46 +
47 + mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
48 + type info struct {
49 + Status string `json:"status"`
50 + }
51 + _ = json.NewEncoder(w).Encode(info{Status: "ok"})
52 + })
53 +
54 + srv := &http.Server{Addr: addr, Handler: mux}
55 + go func() {
56 + log.Info().Msgf("[client] admin http: %s", addr)
57 + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
58 + log.Error().Err(err).Msg("[client] admin http error")
59 + cancel()
60 + }
61 + }()
62 + return srv
63 +}
64 +
65 +var clientTmpl = template.Must(template.New("client-index").Parse(`<!doctype html>
66 +<html lang="ko">
67 +<head>
68 + <meta charset="utf-8"/>
69 + <meta name="viewport" content="width=device-width, initial-scale=1" />
70 + <title>RelayDNS Client</title>
71 + <style>
72 + * { box-sizing: border-box }
73 + :root {
74 + --bg:#fafbff; --panel:#ffffff; --ink:#0f172a; --muted:#6b7280; --line:#e9eef5;
75 + --primary:#2563eb; --ok:#059669; --bad:#b91c1c; --ok-bg:#ecfdf5; --bad-bg:#fee2e2;
76 + }
77 + body { margin:0; background:var(--bg); color:var(--ink); font-family:sans-serif; font-size:16px; line-height:1.6 }
78 + .wrap { max-width: 760px; margin: 0 auto; padding: 28px 18px }
79 + header { display:flex; align-items:center; justify-content:space-between; padding: 16px 20px; background:var(--panel); border:1px solid var(--line); border-radius: 12px }
80 + .brand { font-weight:800; font-size:20px; letter-spacing:.2px }
81 + main { margin-top: 18px }
82 + .section { background:var(--panel); border:1px solid var(--line); border-radius:12px; padding:16px; margin-bottom:12px }
83 + .mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 13px; color:#374151; word-break: break-all }
84 + .title { font-weight:800; margin:0 0 8px 0; font-size:17px }
85 + .muted { color:var(--muted); font-size:14px }
86 + </style>
87 + </head>
88 +<body>
89 + <div class="wrap">
90 + <header>
91 + <div class="brand">RelayDNS Client</div>
92 + <div class="muted">Admin</div>
93 + </header>
94 + <main>
95 + <section class="section">
96 + <div class="title">Client</div>
97 + <div class="muted">Lease ID</div>
98 + <div class="mono">{{.NodeID}}</div>
99 + <div class="muted" style="margin-top:6px">Name</div>
100 + <div class="mono">{{.Name}}</div>
101 + <div class="muted" style="margin-top:6px">ALPNs</div>
102 + <div class="mono">{{range .ALPNs}}{{.}}<br/>{{end}}</div>
103 + </section>
104 + <section class="section">
105 + <div class="title">Bootstraps</div>
106 + {{if .Bootstraps}}
107 + <div class="mono">{{range .Bootstraps}}{{.}}<br/>{{end}}</div>
108 + {{else}}
109 + <div class="muted">No bootstrap URLs configured.</div>
110 + {{end}}
111 + </section>
112 + <section class="section">
113 + <div class="title">Connected Relays</div>
114 + {{if .Relays}}
115 + <div class="mono">{{range .Relays}}{{.}}<br/>{{end}}</div>
116 + {{else}}
117 + <div class="muted">No relays connected.</div>
118 + {{end}}
119 + </section>
120 + </main>
121 + </div>
122 +</body>
123 +</html>`))
cmd/server/main.go
+69 -69
@@ -1,69 +1,69 @@
1 -package main
2 -
3 -import (
4 - "context"
5 - "fmt"
6 - "os/signal"
7 - "syscall"
8 - "time"
9 -
10 - "github.com/rs/zerolog/log"
11 - "github.com/spf13/cobra"
12 -
13 - "github.com/gosuda/relaydns/relaydns"
14 - "github.com/gosuda/relaydns/relaydns/core/cryptoops"
15 -)
16 -
17 -var rootCmd = &cobra.Command{
18 - Use: "relayserver",
19 - Short: "A lightweight, DNS-driven peer-to-peer proxy",
20 - RunE: runServer,
21 -}
22 -
23 -var (
24 - flagPort int // admin UI + HTTP proxy port (e.g. 4017)
25 - bootstraps []string
26 -)
27 -
28 -func init() {
29 - flags := rootCmd.PersistentFlags()
30 - flags.IntVar(&flagPort, "port", 4017, "admin UI and HTTP proxy port")
31 - flags.StringArrayVar(&bootstraps, "bootstraps", nil, "bootstrap addresses")
32 -}
33 -
34 -func main() {
35 - if err := rootCmd.Execute(); err != nil {
36 - log.Fatal().Err(err).Msg("execute root command")
37 - }
38 -}
39 -
40 -func runServer(cmd *cobra.Command, args []string) error {
41 - ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
42 - defer stop()
43 -
44 - cred, err := cryptoops.NewCredential()
45 - if err != nil {
46 - return err
47 - }
48 -
49 - serv := relaydns.NewRelayServer(cred, bootstraps)
50 - serv.Start()
51 - defer serv.Stop()
52 -
53 - // Admin UI + per-peer HTTP proxy
54 - httpSrv := serveHTTP(ctx, fmt.Sprintf(":%d", flagPort), serv, stop)
55 -
56 - <-ctx.Done()
57 - log.Info().Msg("[server] shutting down...")
58 -
59 - shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
60 - defer cancel()
61 - if httpSrv != nil {
62 - if err := httpSrv.Shutdown(shutdownCtx); err != nil {
63 - log.Error().Err(err).Msg("[server] http server shutdown error")
64 - }
65 - }
66 -
67 - log.Info().Msg("[server] shutdown complete")
68 - return nil
69 -}
1 +package main
2 +
3 +import (
4 + "context"
5 + "fmt"
6 + "os/signal"
7 + "syscall"
8 + "time"
9 +
10 + "github.com/rs/zerolog/log"
11 + "github.com/spf13/cobra"
12 +
13 + "github.com/gosuda/relaydns/relaydns"
14 + "github.com/gosuda/relaydns/relaydns/core/cryptoops"
15 +)
16 +
17 +var rootCmd = &cobra.Command{
18 + Use: "relayserver",
19 + Short: "A lightweight, DNS-driven peer-to-peer proxy",
20 + RunE: runServer,
21 +}
22 +
23 +var (
24 + flagPort int // admin UI + HTTP proxy port (e.g. 4017)
25 + bootstraps []string
26 +)
27 +
28 +func init() {
29 + flags := rootCmd.PersistentFlags()
30 + flags.IntVar(&flagPort, "port", 4017, "admin UI and HTTP proxy port")
31 + flags.StringArrayVar(&bootstraps, "bootstraps", nil, "bootstrap addresses")
32 +}
33 +
34 +func main() {
35 + if err := rootCmd.Execute(); err != nil {
36 + log.Fatal().Err(err).Msg("execute root command")
37 + }
38 +}
39 +
40 +func runServer(cmd *cobra.Command, args []string) error {
41 + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
42 + defer stop()
43 +
44 + cred, err := cryptoops.NewCredential()
45 + if err != nil {
46 + return err
47 + }
48 +
49 + serv := relaydns.NewRelayServer(cred, bootstraps)
50 + serv.Start()
51 + defer serv.Stop()
52 +
53 + // Admin UI + per-peer HTTP proxy
54 + httpSrv := serveHTTP(ctx, fmt.Sprintf(":%d", flagPort), serv, cred.ID(), bootstraps, stop)
55 +
56 + <-ctx.Done()
57 + log.Info().Msg("[server] shutting down...")
58 +
59 + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
60 + defer cancel()
61 + if httpSrv != nil {
62 + if err := httpSrv.Shutdown(shutdownCtx); err != nil {
63 + log.Error().Err(err).Msg("[server] http server shutdown error")
64 + }
65 + }
66 +
67 + log.Info().Msg("[server] shutdown complete")
68 + return nil
69 +}
cmd/server/view.go
+189 -134
@@ -1,134 +1,189 @@
1 -package main
2 -
3 -import (
4 - "context"
5 - "encoding/json"
6 - "html/template"
7 - "net/http"
8 -
9 - "github.com/rs/zerolog/log"
10 -
11 - "github.com/gosuda/relaydns/relaydns"
12 -)
13 -
14 -// serveHTTP builds the HTTP mux and returns the server.
15 -func serveHTTP(ctx context.Context, addr string, serv *relaydns.RelayServer, cancel context.CancelFunc) *http.Server {
16 - if addr == "" {
17 - addr = ":0"
18 - }
19 -
20 - mux := http.NewServeMux()
21 -
22 - // Index page
23 - mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
24 - if r.URL.Path != "/" {
25 - http.NotFound(w, r)
26 - return
27 - }
28 -
29 - w.Header().Set("Content-Type", "text/html; charset=utf-8")
30 - log.Debug().Msg("render admin index")
31 - _ = serverTmpl.Execute(w, nil) // TODO
32 - })
33 -
34 - mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
35 - type info struct {
36 - Status string `json:"status"`
37 - }
38 - resp := info{Status: "ok"}
39 - w.Header().Set("Content-Type", "application/json")
40 - _ = json.NewEncoder(w).Encode(resp)
41 - })
42 -
43 - srv := &http.Server{
44 - Addr: addr,
45 - Handler: mux,
46 - }
47 -
48 - go func() {
49 - log.Info().Msgf("[server] http: %s", addr)
50 - if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
51 - log.Error().Err(err).Msg("[server] http error")
52 - cancel()
53 - }
54 - }()
55 -
56 - return srv
57 -}
58 -
59 -var serverTmpl = template.Must(template.New("admin-index").Parse(`<!doctype html>
60 -<html lang="ko">
61 -<head>
62 - <meta charset="utf-8"/>
63 - <meta name="viewport" content="width=device-width, initial-scale=1" />
64 - <title>RelayDNS — Admin</title>
65 - <style>
66 - * { box-sizing: border-box }
67 - :root {
68 - --bg:#fafbff; --panel:#ffffff; --ink:#0f172a; --muted:#6b7280; --line:#e9eef5;
69 - --primary:#2563eb; --ok:#059669; --bad:#b91c1c; --ok-bg:#ecfdf5; --bad-bg:#fee2e2;
70 - }
71 - body { margin:0; background:var(--bg); color:var(--ink); font-family:sans-serif; font-size:16px; line-height:1.6 }
72 - .wrap { max-width: 980px; margin: 0 auto; padding: 32px 20px }
73 - header { display:flex; align-items:center; justify-content:space-between; padding: 20px 24px; background:var(--panel); border:1px solid var(--line); border-radius: 14px }
74 - .brand { font-weight:800; font-size:22px; letter-spacing:.2px }
75 - .status { color:var(--ok); font-weight:700 }
76 - main { margin-top: 22px }
77 - .section { background:var(--panel); border:1px solid var(--line); border-radius:14px; padding:18px; margin-bottom:14px }
78 - .mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 13px; color:#374151; word-break: break-all }
79 - .title { font-weight:800; margin:0 0 10px 0; font-size:18px }
80 - .muted { color:var(--muted); font-size:14px }
81 - .pill { display:inline-flex; align-items:center; gap:8px; padding:6px 10px; border-radius:999px; font-weight:800; font-size:13px }
82 - .pill.ok { background:var(--ok-bg); color:var(--ok) }
83 - .pill.bad { background:var(--bad-bg); color:var(--bad) }
84 - .pill .dot { width:8px; height:8px; border-radius:999px; background:var(--ok); display:inline-block }
85 - .pill.bad .dot { background:var(--bad) }
86 - .head { display:flex; align-items:center; justify-content:space-between; gap:12px }
87 - .btn { display:inline-block; background:var(--primary); color:#fff; text-decoration:none; border-radius:10px; padding:10px 14px; font-weight:800; margin-top:8px }
88 - </style>
89 - </head>
90 -<body>
91 - <div class="wrap">
92 - <header>
93 - <div class="brand">RelayDNS</div>
94 - <div class="status">Admin</div>
95 - </header>
96 - <main>
97 - <section class="section">
98 - <div class="title">Server</div>
99 - <div class="mono">Peer ID: {{.NodeID}}</div>
100 - {{if .Addrs}}
101 - <div class="muted" style="margin-top:6px">Multiaddrs</div>
102 - <div class="mono">{{range .Addrs}}{{.}}<br/>{{end}}</div>
103 - {{end}}
104 - <div class="muted" style="margin-top:6px">Known clients: {{len .Rows}}</div>
105 - </section>
106 - {{range .Rows}}
107 - <section class="section" id="peer-{{.Peer}}" data-peer="{{.Peer}}" data-name="{{.Name}}">
108 - <div class="head">
109 - <div class="title">{{if .Name}}{{.Name}}{{else}}(unnamed){{end}}</div>
110 - <div>
111 - <span class="muted" style="margin-right:8px">{{.Kind}}</span>
112 - {{if .Connected}}
113 - <span class="pill ok"><span class="dot"></span>Connected</span>
114 - {{else}}
115 - <span class="pill bad"><span class="dot"></span>Disconnected</span>
116 - {{end}}
117 - </div>
118 - </div>
119 - {{if .DNS}}<div class="muted">DNS: <span class="mono">{{.DNS}}</span></div>{{end}}
120 - <div class="muted">Peer</div>
121 - <div class="mono">{{.Peer}}</div>
122 - <div class="muted" style="margin-top:6px">Last seen: {{.LastSeen}}{{if .TTL}} · TTL: {{.TTL}}{{end}}</div>
123 - <a class="btn" href="{{.Link}}">Open</a>
124 - </section>
125 - {{else}}
126 - <section class="section">
127 - <div class="title">No clients discovered</div>
128 - <div class="muted">Start a client and ensure bootstraps are configured.</div>
129 - </section>
130 - {{end}}
131 - </main>
132 - </div>
133 -</body>
134 -</html>`))
1 +package main
2 +
3 +import (
4 + "context"
5 + "encoding/json"
6 + "html/template"
7 + "net/http"
8 +
9 + "github.com/gorilla/websocket"
10 + "github.com/rs/zerolog/log"
11 +
12 + "github.com/gosuda/relaydns/relaydns"
13 + "github.com/gosuda/relaydns/relaydns/utils/wsstream"
14 +)
15 +
16 +type leaseRow struct {
17 + Peer string
18 + Name string
19 + Kind string
20 + Connected bool
21 + DNS string
22 + LastSeen string
23 + TTL string
24 + Link string
25 +}
26 +
27 +type adminPageData struct {
28 + NodeID string
29 + Bootstraps []string
30 + Rows []leaseRow
31 +}
32 +
33 +var wsUpgrader = websocket.Upgrader{
34 + ReadBufferSize: 1024,
35 + WriteBufferSize: 1024,
36 + CheckOrigin: func(r *http.Request) bool {
37 + return true
38 + },
39 +}
40 +
41 +// serveHTTP builds the HTTP mux and returns the server.
42 +func serveHTTP(ctx context.Context, addr string, serv *relaydns.RelayServer, nodeID string, bootstraps []string, cancel context.CancelFunc) *http.Server {
43 + if addr == "" {
44 + addr = ":0"
45 + }
46 +
47 + mux := http.NewServeMux()
48 +
49 + mux.HandleFunc("/relay", func(w http.ResponseWriter, r *http.Request) {
50 + if r.Method != http.MethodGet {
51 + w.Header().Set("Allow", http.MethodGet)
52 + http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
53 + return
54 + }
55 +
56 + wsConn, err := wsUpgrader.Upgrade(w, r, nil)
57 + if err != nil {
58 + log.Error().Err(err).Msg("[server] websocket upgrade failed")
59 + return
60 + }
61 +
62 + stream := &wsstream.WsStream{Conn: wsConn}
63 + if err := serv.HandleConnection(stream); err != nil {
64 + log.Error().Err(err).Msg("[server] websocket relay connection error")
65 + wsConn.Close()
66 + return
67 + }
68 + })
69 +
70 + // Index page
71 + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
72 + if r.URL.Path != "/" {
73 + http.NotFound(w, r)
74 + return
75 + }
76 +
77 + data := adminPageData{
78 + NodeID: nodeID,
79 + Bootstraps: bootstraps,
80 + }
81 +
82 + w.Header().Set("Content-Type", "text/html; charset=utf-8")
83 + log.Debug().Msg("render admin index")
84 + if err := serverTmpl.Execute(w, data); err != nil {
85 + log.Error().Err(err).Msg("[server] render admin index")
86 + }
87 + })
88 +
89 + mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
90 + type info struct {
91 + Status string `json:"status"`
92 + }
93 + resp := info{Status: "ok"}
94 + w.Header().Set("Content-Type", "application/json")
95 + _ = json.NewEncoder(w).Encode(resp)
96 + })
97 +
98 + srv := &http.Server{
99 + Addr: addr,
100 + Handler: mux,
101 + }
102 +
103 + go func() {
104 + log.Info().Msgf("[server] http: %s", addr)
105 + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
106 + log.Error().Err(err).Msg("[server] http error")
107 + cancel()
108 + }
109 + }()
110 +
111 + return srv
112 +}
113 +
114 +var serverTmpl = template.Must(template.New("admin-index").Parse(`<!doctype html>
115 +<html lang="ko">
116 +<head>
117 + <meta charset="utf-8"/>
118 + <meta name="viewport" content="width=device-width, initial-scale=1" />
119 + <title>RelayDNS Admin</title>
120 + <style>
121 + * { box-sizing: border-box }
122 + :root {
123 + --bg:#fafbff; --panel:#ffffff; --ink:#0f172a; --muted:#6b7280; --line:#e9eef5;
124 + --primary:#2563eb; --ok:#059669; --bad:#b91c1c; --ok-bg:#ecfdf5; --bad-bg:#fee2e2;
125 + }
126 + body { margin:0; background:var(--bg); color:var(--ink); font-family:sans-serif; font-size:16px; line-height:1.6 }
127 + .wrap { max-width: 980px; margin: 0 auto; padding: 32px 20px }
128 + header { display:flex; align-items:center; justify-content:space-between; padding: 20px 24px; background:var(--panel); border:1px solid var(--line); border-radius: 14px }
129 + .brand { font-weight:800; font-size:22px; letter-spacing:.2px }
130 + .status { color:var(--ok); font-weight:700 }
131 + main { margin-top: 22px }
132 + .section { background:var(--panel); border:1px solid var(--line); border-radius:14px; padding:18px; margin-bottom:14px }
133 + .mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 13px; color:#374151; word-break: break-all }
134 + .title { font-weight:800; margin:0 0 10px 0; font-size:18px }
135 + .muted { color:var(--muted); font-size:14px }
136 + .pill { display:inline-flex; align-items:center; gap:8px; padding:6px 10px; border-radius:999px; font-weight:800; font-size:13px }
137 + .pill.ok { background:var(--ok-bg); color:var(--ok) }
138 + .pill.bad { background:var(--bad-bg); color:var(--bad) }
139 + .pill .dot { width:8px; height:8px; border-radius:999px; background:var(--ok); display:inline-block }
140 + .pill.bad .dot { background:var(--bad) }
141 + .head { display:flex; align-items:center; justify-content:space-between; gap:12px }
142 + .btn { display:inline-block; background:var(--primary); color:#fff; text-decoration:none; border-radius:10px; padding:10px 14px; font-weight:800; margin-top:8px }
143 + </style>
144 + </head>
145 +<body>
146 + <div class="wrap">
147 + <header>
148 + <div class="brand">RelayDNS</div>
149 + <div class="status">Admin</div>
150 + </header>
151 + <main>
152 + <section class="section">
153 + <div class="title">Server</div>
154 + <div class="mono">Server ID: {{.NodeID}}</div>
155 + {{if .Bootstraps}}
156 + <div class="muted" style="margin-top:6px">Bootstrap URLs</div>
157 + <div class="mono">{{range .Bootstraps}}<a href="{{.}}" target="_blank" rel="noreferrer noopener">{{.}}</a><br/>{{end}}</div>
158 + {{end}}
159 + <div class="muted" style="margin-top:6px">Active clients: {{len .Rows}}</div>
160 + </section>
161 + {{range .Rows}}
162 + <section class="section" id="peer-{{.Peer}}" data-peer="{{.Peer}}" data-name="{{.Name}}">
163 + <div class="head">
164 + <div class="title">{{if .Name}}{{.Name}}{{else}}(unnamed){{end}}</div>
165 + <div>
166 + <span class="muted" style="margin-right:8px">{{.Kind}}</span>
167 + {{if .Connected}}
168 + <span class="pill ok"><span class="dot"></span>Connected</span>
169 + {{else}}
170 + <span class="pill bad"><span class="dot"></span>Disconnected</span>
171 + {{end}}
172 + </div>
173 + </div>
174 + {{if .DNS}}<div class="muted">DNS Label: <span class="mono">{{.DNS}}</span></div>{{end}}
175 + <div class="muted">Lease Identity</div>
176 + <div class="mono">{{.Peer}}</div>
177 + <div class="muted" style="margin-top:6px">Last seen: {{.LastSeen}}{{if .TTL}} - TTL: {{.TTL}}{{end}}</div>
178 + <a class="btn" href="{{.Link}}">Open</a>
179 + </section>
180 + {{else}}
181 + <section class="section">
182 + <div class="title">No clients discovered</div>
183 + <div class="muted">Start a client and ensure bootstrap URLs point at this server's /relay WebSocket endpoint.</div>
184 + </section>
185 + {{end}}
186 + </main>
187 + </div>
188 +</body>
189 +</html>`))