feat: add optional TCP ingress support and enhance client advertisement structure

Kim committed Oct 21, 2025 at 14:52 UTC 2771f6d0d44f6318fd8ae6023a006991a6a74a06
5 files changed +150 -70
cmd/server/main.go
+47
@@ -2,6 +2,7 @@ package main
2
3 import (
4 "context"
5 + "net"
6 "os"
7 "os/signal"
8 "syscall"
@@ -24,6 +25,7 @@ var (
25 httpAddr string // unified admin + HTTP proxy (e.g. :8080)
26 protocol string
27 topic string
28 + tcpAddr string // optional raw TCP ingress (e.g. :2222 for SSH)
29 )
30
31 func init() {
@@ -33,6 +35,7 @@ func init() {
35 flags.StringVar(&httpAddr, "http", ":8080", "Unified admin UI and HTTP proxy listen address")
36 flags.StringVar(&protocol, "protocol", "/relaydns/http/1.0", "libp2p protocol id for streams (must match clients)")
37 flags.StringVar(&topic, "topic", "relaydns.backends", "pubsub topic for backend adverts")
38 + flags.StringVar(&tcpAddr, "tcp", "", "Optional raw TCP ingress (e.g. :2222 for SSH). Empty to disable")
39 }
40
41 func main() {
@@ -60,6 +63,11 @@ func runServer(cmd *cobra.Command, args []string) error {
63 // Admin UI + per-peer HTTP proxy served here
64 go serveHTTP(ctx, httpAddr, d, h, cancel)
65
66 + // Optional raw TCP ingress (e.g., SSH)
67 + if tcpAddr != "" {
68 + go serveTCPIngress(ctx, tcpAddr, d)
69 + }
70 +
71 // graceful shutdown
72 sig := make(chan os.Signal, 1)
73 signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
@@ -68,3 +76,42 @@ func runServer(cmd *cobra.Command, args []string) error {
76 time.Sleep(300 * time.Millisecond)
77 return nil
78 }
79 +
80 +// serveTCPIngress listens on addr for raw TCP (e.g., SSH) and proxies
81 +// incoming connections to a chosen peer over libp2p stream using Director.
82 +func serveTCPIngress(ctx context.Context, addr string, d *relaydns.Director) {
83 + ln, err := net.Listen("tcp", addr)
84 + if err != nil {
85 + log.Error().Err(err).Msgf("tcp ingress listen failed: %s", addr)
86 + return
87 + }
88 + log.Info().Msgf("[server] tcp ingress: %s", addr)
89 + go func() {
90 + <-ctx.Done()
91 + _ = ln.Close()
92 + }()
93 + for {
94 + conn, err := ln.Accept()
95 + if err != nil {
96 + select {
97 + case <-ctx.Done():
98 + return
99 + default:
100 + }
101 + continue
102 + }
103 + go func(c net.Conn) {
104 + hosts := d.Hosts()
105 + if len(hosts) == 0 {
106 + log.Warn().Msg("tcp ingress: no backend peers available")
107 + _ = c.Close()
108 + return
109 + }
110 + // pick most recent (Hosts() sorted by last seen)
111 + peerID := hosts[0].Info.Peer
112 + if err := d.ProxyTCP(c, peerID); err != nil {
113 + log.Warn().Err(err).Msgf("tcp ingress proxy failed to %s", peerID)
114 + }
115 + }(conn)
116 + }
117 + }
cmd/server/view.go
+67 -62
@@ -1,13 +1,13 @@
1 package main
2
3 import (
4 - "context"
5 - "encoding/json"
6 - "fmt"
7 - "html/template"
8 - "net/http"
9 - "strings"
10 - "time"
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"
@@ -27,22 +27,26 @@ func serveHTTP(ctx context.Context, addr string, d *relaydns.Director, h host.Ho
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 - }
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 + kind := "TCP"
37 + if strings.Contains(v.Info.Proto, "/http/") { kind = "HTTP" }
38 + if strings.Contains(v.Info.Proto, "/ssh/") { kind = "SSH" }
39 + rows = append(rows, row{
40 + Peer: v.Info.Peer,
41 + Name: v.Info.Name,
42 + DNS: v.Info.DNS,
43 + LastSeen: time.Since(v.LastSeen).Round(time.Second).String() + " ago",
44 + Link: "/peer/" + v.Info.Peer + "/",
45 + TTL: ttl,
46 + Connected: v.Connected,
47 + Kind: kind,
48 + })
49 + }
50 w.Header().Set("Content-Type", "text/html; charset=utf-8")
51 log.Debug().Int("clients", len(rows)).Msg("render admin index")
52 _ = adminIndexTmpl.Execute(w, page{
@@ -93,13 +97,14 @@ func serveHTTP(ctx context.Context, addr string, d *relaydns.Director, h host.Ho
97
98 // view model types used by template rendering
99 type row struct {
96 - Peer string
97 - Name string
98 - DNS string
99 - LastSeen string
100 - Link string
101 - TTL string
102 - Connected bool
100 + Peer string
101 + Name string
102 + DNS string
103 + LastSeen string
104 + Link string
105 + TTL string
106 + Connected bool
107 + Kind string
108 }
109
110 type page struct {
@@ -124,40 +129,37 @@ var adminIndexTmpl = template.Must(template.New("admin-index").Parse(`<!doctype
129 <title>RelayDNS — Admin</title>
130 <style>
131 * { 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;
132 + :root {
133 + --bg:#fafbff; --panel:#ffffff; --ink:#0f172a; --muted:#6b7280; --line:#e9eef5;
134 + --primary:#2563eb; --ok:#059669; --bad:#b91c1c; --ok-bg:#ecfdf5; --bad-bg:#fee2e2;
135 }
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 }
136 + body { margin:0; background:var(--bg); color:var(--ink); font-family:sans-serif; font-size:16px; line-height:1.6 }
137 + .wrap { max-width: 980px; margin: 0 auto; padding: 32px 20px }
138 + header { display:flex; align-items:center; justify-content:space-between; padding: 20px 24px; background:var(--panel); border:1px solid var(--line); border-radius: 14px }
139 + .brand { font-weight:800; font-size:22px; letter-spacing:.2px }
140 + .status { color:var(--ok); font-weight:700 }
141 + main { margin-top: 22px }
142 + .section { background:var(--panel); border:1px solid var(--line); border-radius:14px; padding:18px; margin-bottom:14px }
143 + .mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 13px; color:#374151; word-break: break-all }
144 + .title { font-weight:800; margin:0 0 10px 0; font-size:18px }
145 + .muted { color:var(--muted); font-size:14px }
146 + .pill { display:inline-flex; align-items:center; gap:8px; padding:6px 10px; border-radius:999px; font-weight:800; font-size:13px }
147 + .pill.ok { background:var(--ok-bg); color:var(--ok) }
148 + .pill.bad { background:var(--bad-bg); color:var(--bad) }
149 + .pill .dot { width:8px; height:8px; border-radius:999px; background:var(--ok); display:inline-block }
150 + .pill.bad .dot { background:var(--bad) }
151 .head { display:flex; align-items:center; justify-content:space-between; gap:12px }
152 + .btn { display:inline-block; background:var(--primary); color:#fff; text-decoration:none; border-radius:10px; padding:10px 14px; font-weight:800; margin-top:8px }
153 </style>
154 </head>
155 <body>
156 <div class="wrap">
157 <header>
156 - <div class="brand">RelayDNS Admin</div>
157 - <div class="status">Active</div>
158 + <div class="brand">RelayDNS</div>
159 + <div class="status">Admin</div>
160 </header>
161 <main>
160 - <section class="box">
162 + <section class="section">
163 <div class="title">Server</div>
164 <div class="mono">Peer ID: {{.NodeID}}</div>
165 {{if .Addrs}}
@@ -167,14 +169,17 @@ var adminIndexTmpl = template.Must(template.New("admin-index").Parse(`<!doctype
169 <div class="muted" style="margin-top:6px">Known clients: {{len .Rows}}</div>
170 </section>
171 {{range .Rows}}
170 - <section class="box">
172 + <section class="section">
173 <div class="head">
174 <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}}
175 + <div>
176 + <span class="muted" style="margin-right:8px">{{.Kind}}</span>
177 + {{if .Connected}}
178 + <span class="pill ok"><span class="dot"></span>Connected</span>
179 + {{else}}
180 + <span class="pill bad"><span class="dot"></span>Disconnected</span>
181 + {{end}}
182 + </div>
183 </div>
184 {{if .DNS}}<div class="muted">DNS: <span class="mono">{{.DNS}}</span></div>{{end}}
185 <div class="muted">Peer</div>
@@ -183,7 +188,7 @@ var adminIndexTmpl = template.Must(template.New("admin-index").Parse(`<!doctype
188 <a class="btn" href="{{.Link}}">Open</a>
189 </section>
190 {{else}}
186 - <section class="box">
191 + <section class="section">
192 <div class="title">No clients discovered</div>
193 <div class="muted">Start a client and ensure bootstraps are configured.</div>
194 </section>
relaydns/client.go
+1
@@ -183,6 +183,7 @@ func (b *RelayClient) Start(ctx context.Context) error {
183 Load: 0.0,
184 TS: time.Now().UTC(),
185 TTL: int(b.cfg.AdvertiseTTL.Seconds()),
186 + Proto: string(b.protoID),
187 }
188 payload, _ := json.Marshal(ad)
189 _ = b.t.Publish(advCtx, payload)
relaydns/director.go
+26
@@ -4,7 +4,9 @@ import (
4 "bufio"
5 "context"
6 "encoding/json"
7 + "fmt"
8 "io"
9 + "net"
10 "net/http"
11 "net/url"
12 "sort"
@@ -216,3 +218,27 @@ func (d *Director) ProxyHTTP(w http.ResponseWriter, r *http.Request, peerID, pat
218 w.WriteHeader(resp.StatusCode)
219 _, _ = io.Copy(w, resp.Body)
220 }
221 +
222 +// ProxyTCP opens a libp2p stream to peerID using the Director protocol and
223 +// pipes raw bytes between the accepted TCP connection and the libp2p stream.
224 +func (d *Director) ProxyTCP(c net.Conn, peerID string) error {
225 + defer c.Close()
226 + d.storeMu.Lock()
227 + entry, ok := d.store[peerID]
228 + d.storeMu.Unlock()
229 + if !ok || entry.AddrInfo == nil {
230 + return fmt.Errorf("peer not found")
231 + }
232 + if err := d.h.Connect(d.ctx, *entry.AddrInfo); err != nil {
233 + return err
234 + }
235 + s, err := d.h.NewStream(d.ctx, entry.AddrInfo.ID, protocolID(d.protocol))
236 + if err != nil {
237 + return err
238 + }
239 + defer s.Close()
240 + // bidirectional copy
241 + go io.Copy(s, c)
242 + _, _ = io.Copy(c, s)
243 + return nil
244 +}
relaydns/types.go
+9 -8
@@ -8,14 +8,15 @@ import (
8 )
9
10 type Advertise struct {
11 - Peer string `json:"peer"`
12 - Name string `json:"name,omitempty"`
13 - DNS string `json:"dns,omitempty"`
14 - Addrs []string `json:"addrs"`
15 - Ready bool `json:"ready"`
16 - Load float64 `json:"load"`
17 - TS time.Time `json:"ts"`
18 - TTL int `json:"ttl,omitempty"` // seconds
11 + Peer string `json:"peer"`
12 + Name string `json:"name,omitempty"`
13 + DNS string `json:"dns,omitempty"`
14 + Addrs []string `json:"addrs"`
15 + Ready bool `json:"ready"`
16 + Load float64 `json:"load"`
17 + TS time.Time `json:"ts"`
18 + TTL int `json:"ttl,omitempty"` // seconds
19 + Proto string `json:"proto,omitempty"`
20 }
21
22 type HostEntry struct {