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>`))