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 {