feat: change resolve server for peer id using title.
Hee Sung Son committed
Oct 29, 2025 at 11:33 UTC
081d018bacde9caf03e9a86b8c473a119bae4949
7 files changed
+411
-25
cmd/demo-app/static/index.html
+26
-21
@@ -1,22 +1,15 @@
1
<!DOCTYPE html>
2
<html lang="en">
3
+
4
<head>
5
<meta charset="UTF-8">
6
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
7
<meta name="apple-mobile-web-app-capable" content="yes">
8
<meta name="mobile-web-app-capable" content="yes">
9
<title>RelayDNS Paint</title>
9
- <script>
10
- // Set base URL for relative paths - must be before any relative resource loads
11
- const base = document.createElement('base');
12
- const path = window.location.pathname;
13
- // Ensure trailing slash for proper relative path resolution
14
- base.href = path.endsWith('/') ? path : (path + '/');
15
- document.head.appendChild(base);
16
- console.log('[Paint] Base path set to:', base.href);
17
- </script>
10
<link rel="stylesheet" href="style.css">
11
</head>
12
+
13
<body>
14
<div class="container">
15
<h1>🎨 RelayDNS Collaborative Paint</h1>
@@ -35,18 +28,29 @@
28
<label for="color">Color:</label>
29
<input type="color" id="color" value="#000000">
30
<div class="color-presets">
38
- <button class="color-preset" data-color="#000000" style="background: #000000;" title="Black"></button>
39
- <button class="color-preset" data-color="#ffffff" style="background: #ffffff; border: 1px solid #ddd;" title="White"></button>
31
+ <button class="color-preset" data-color="#000000" style="background: #000000;"
32
+ title="Black"></button>
33
+ <button class="color-preset" data-color="#ffffff"
34
+ style="background: #ffffff; border: 1px solid #ddd;" title="White"></button>
35
<button class="color-preset" data-color="#ef4444" style="background: #ef4444;" title="Red"></button>
41
- <button class="color-preset" data-color="#f97316" style="background: #f97316;" title="Orange"></button>
42
- <button class="color-preset" data-color="#eab308" style="background: #eab308;" title="Yellow"></button>
43
- <button class="color-preset" data-color="#22c55e" style="background: #22c55e;" title="Green"></button>
44
- <button class="color-preset" data-color="#06b6d4" style="background: #06b6d4;" title="Cyan"></button>
45
- <button class="color-preset" data-color="#3b82f6" style="background: #3b82f6;" title="Blue"></button>
46
- <button class="color-preset" data-color="#8b5cf6" style="background: #8b5cf6;" title="Purple"></button>
47
- <button class="color-preset" data-color="#ec4899" style="background: #ec4899;" title="Pink"></button>
48
- <button class="color-preset" data-color="#a855f7" style="background: #a855f7;" title="Violet"></button>
49
- <button class="color-preset" data-color="#6b7280" style="background: #6b7280;" title="Gray"></button>
36
+ <button class="color-preset" data-color="#f97316" style="background: #f97316;"
37
+ title="Orange"></button>
38
+ <button class="color-preset" data-color="#eab308" style="background: #eab308;"
39
+ title="Yellow"></button>
40
+ <button class="color-preset" data-color="#22c55e" style="background: #22c55e;"
41
+ title="Green"></button>
42
+ <button class="color-preset" data-color="#06b6d4" style="background: #06b6d4;"
43
+ title="Cyan"></button>
44
+ <button class="color-preset" data-color="#3b82f6" style="background: #3b82f6;"
45
+ title="Blue"></button>
46
+ <button class="color-preset" data-color="#8b5cf6" style="background: #8b5cf6;"
47
+ title="Purple"></button>
48
+ <button class="color-preset" data-color="#ec4899" style="background: #ec4899;"
49
+ title="Pink"></button>
50
+ <button class="color-preset" data-color="#a855f7" style="background: #a855f7;"
51
+ title="Violet"></button>
52
+ <button class="color-preset" data-color="#6b7280" style="background: #6b7280;"
53
+ title="Gray"></button>
54
</div>
55
</div>
56
@@ -68,4 +72,5 @@
72
73
<script src="app.js"></script>
74
</body>
71
-</html>
75
+
76
+</html>
\ No newline at end of file
cmd/relay-server/view.go
+41
-3
@@ -62,19 +62,52 @@ func serveHTTP(ctx context.Context, addr string, serv *relaydns.RelayServer, nod
62
}
63
64
mux.HandleFunc("/peer/", func(w http.ResponseWriter, r *http.Request) {
65
- // Expect path /peer/{leaseID}[/{rest}]
65
+ // Expect path /peer/{nameOrID}[/{rest}]
66
path := strings.TrimPrefix(r.URL.Path, "/peer/")
67
if path == "" {
68
http.NotFound(w, r)
69
return
70
}
71
parts := strings.SplitN(path, "/", 2)
72
- leaseID := parts[0]
72
+ nameOrID := parts[0]
73
rest := "/"
74
if len(parts) == 2 && parts[1] != "" {
75
rest = "/" + parts[1]
76
}
77
78
+ // Redirect /peer/my-title to /peer/my-title/ for proper relative path resolution
79
+ // This ensures that relative paths like "style.css" resolve to "/peer/my-title/style.css"
80
+ // instead of "/peer/style.css"
81
+ if len(parts) == 1 && !strings.HasSuffix(r.URL.Path, "/") {
82
+ redirectURL := r.URL.Path + "/"
83
+ if r.URL.RawQuery != "" {
84
+ redirectURL += "?" + r.URL.RawQuery
85
+ }
86
+ http.Redirect(w, r, redirectURL, http.StatusMovedPermanently)
87
+ return
88
+ }
89
+
90
+ // URL decode the name (handles Unicode like 한글 → %ED%95%9C%EA%B8%80)
91
+ decodedName, err := url.QueryUnescape(nameOrID)
92
+ if err != nil {
93
+ log.Warn().Err(err).Str("name", nameOrID).Msg("[server] Failed to decode peer name")
94
+ decodedName = nameOrID // Fallback to original if decode fails
95
+ }
96
+
97
+ // Try to find lease by name first, then by ID
98
+ leaseID := ""
99
+ leaseEntries := serv.GetAllLeaseEntries()
100
+ for _, entry := range leaseEntries {
101
+ if entry.Lease.Name == decodedName {
102
+ leaseID = string(entry.Lease.Identity.Id)
103
+ break
104
+ }
105
+ }
106
+ // If not found by name, assume it's an ID
107
+ if leaseID == "" {
108
+ leaseID = decodedName
109
+ }
110
+
111
// Get ALPN from lease metadata
112
alpns := serv.GetLeaseALPNs(leaseID)
113
if len(alpns) == 0 {
@@ -292,7 +325,12 @@ func convertLeaseEntriesToRows(serv *relaydns.RelayServer) []leaseRow {
325
}
326
327
// Create link for the lease
295
- link := fmt.Sprintf("/peer/%s", identityID)
328
+ // Use name if available, otherwise fall back to identity ID
329
+ linkPath := name
330
+ if linkPath == "" || linkPath == "(unnamed)" {
331
+ linkPath = identityID
332
+ }
333
+ link := fmt.Sprintf("/peer/%s", linkPath)
334
335
row := leaseRow{
336
Peer: identityID,
relaydns/handlers.go
+8
-1
@@ -76,10 +76,17 @@ func (g *RelayServer) handleLeaseUpdateRequest(ctx *StreamContext, packet *rdver
76
// Log lease update completion
77
log.Debug().
78
Str("lease_id", leaseID).
79
+ Str("lease_name", req.Lease.Name).
80
Int64("connection_id", ctx.ConnectionID).
81
Msg("[RelayServer] Lease update completed successfully")
82
} else {
82
- resp.Code = rdverb.ResponseCode_RESPONSE_CODE_INVALID_EXPIRES
83
+ // Lease update failed (could be expired or name conflict)
84
+ leaseID := string(req.Lease.Identity.Id)
85
+ log.Warn().
86
+ Str("lease_id", leaseID).
87
+ Str("lease_name", req.Lease.Name).
88
+ Msg("[RelayServer] Lease update rejected (expired or name conflict)")
89
+ resp.Code = rdverb.ResponseCode_RESPONSE_CODE_REJECTED
90
}
91
92
response, err := resp.MarshalVT()
relaydns/lease.go
+15
@@ -76,6 +76,21 @@ func (lm *LeaseManager) UpdateLease(lease *rdverb.Lease, connectionID int64) boo
76
return false
77
}
78
79
+ // Check for name conflicts (only if name is not empty)
80
+ if lease.Name != "" && lease.Name != "(unnamed)" {
81
+ for existingID, existingEntry := range lm.leases {
82
+ // Skip if it's the same identity (updating own lease)
83
+ if existingID == identityID {
84
+ continue
85
+ }
86
+ // Check if another identity is using the same name
87
+ if existingEntry.Lease.Name == lease.Name {
88
+ // Name conflict with a different identity
89
+ return false
90
+ }
91
+ }
92
+ }
93
+
94
lm.leases[identityID] = &LeaseEntry{
95
Lease: lease,
96
Expires: expires,
relaydns/lease_test.go
new
+225
@@ -0,0 +1,225 @@
1
+package relaydns
2
+
3
+import (
4
+ "testing"
5
+ "time"
6
+
7
+ "github.com/gosuda/relaydns/relaydns/core/proto/rdsec"
8
+ "github.com/gosuda/relaydns/relaydns/core/proto/rdverb"
9
+)
10
+
11
+func TestLeaseManager_NameConflict(t *testing.T) {
12
+ lm := NewLeaseManager(30 * time.Second)
13
+ defer lm.Stop()
14
+
15
+ // Create two different identities
16
+ identity1 := &rdsec.Identity{
17
+ Id: "identity-1",
18
+ PublicKey: []byte("public-key-1"),
19
+ }
20
+
21
+ identity2 := &rdsec.Identity{
22
+ Id: "identity-2",
23
+ PublicKey: []byte("public-key-2"),
24
+ }
25
+
26
+ // Lease 1 with name "my-service"
27
+ lease1 := &rdverb.Lease{
28
+ Identity: identity1,
29
+ Name: "my-service",
30
+ Alpn: []string{"http/1.1"},
31
+ Expires: time.Now().Add(10 * time.Minute).Unix(),
32
+ }
33
+
34
+ // Lease 2 with the same name "my-service" but different identity
35
+ lease2 := &rdverb.Lease{
36
+ Identity: identity2,
37
+ Name: "my-service",
38
+ Alpn: []string{"http/1.1"},
39
+ Expires: time.Now().Add(10 * time.Minute).Unix(),
40
+ }
41
+
42
+ // First lease should succeed
43
+ if !lm.UpdateLease(lease1, 1) {
44
+ t.Fatal("First lease registration should succeed")
45
+ }
46
+
47
+ // Second lease with same name should fail (name conflict)
48
+ if lm.UpdateLease(lease2, 2) {
49
+ t.Fatal("Second lease registration should fail due to name conflict")
50
+ }
51
+
52
+ // Verify only first lease exists
53
+ entry, exists := lm.GetLeaseByID(string(identity1.Id))
54
+ if !exists {
55
+ t.Fatal("First lease should exist")
56
+ }
57
+ if entry.Lease.Name != "my-service" {
58
+ t.Errorf("Expected lease name 'my-service', got '%s'", entry.Lease.Name)
59
+ }
60
+
61
+ // Verify second lease was not added
62
+ _, exists = lm.GetLeaseByID(string(identity2.Id))
63
+ if exists {
64
+ t.Fatal("Second lease should not exist due to name conflict")
65
+ }
66
+}
67
+
68
+func TestLeaseManager_SameIdentityUpdate(t *testing.T) {
69
+ lm := NewLeaseManager(30 * time.Second)
70
+ defer lm.Stop()
71
+
72
+ identity := &rdsec.Identity{
73
+ Id: "identity-1",
74
+ PublicKey: []byte("public-key-1"),
75
+ }
76
+
77
+ // Initial lease with name "my-service"
78
+ lease1 := &rdverb.Lease{
79
+ Identity: identity,
80
+ Name: "my-service",
81
+ Alpn: []string{"http/1.1"},
82
+ Expires: time.Now().Add(10 * time.Minute).Unix(),
83
+ }
84
+
85
+ // Updated lease with same identity and same name
86
+ lease2 := &rdverb.Lease{
87
+ Identity: identity,
88
+ Name: "my-service",
89
+ Alpn: []string{"http/1.1", "h2"},
90
+ Expires: time.Now().Add(15 * time.Minute).Unix(),
91
+ }
92
+
93
+ // First registration
94
+ if !lm.UpdateLease(lease1, 1) {
95
+ t.Fatal("First lease registration should succeed")
96
+ }
97
+
98
+ // Update with same identity should succeed (no conflict)
99
+ if !lm.UpdateLease(lease2, 1) {
100
+ t.Fatal("Updating own lease should succeed")
101
+ }
102
+
103
+ // Verify lease was updated
104
+ entry, exists := lm.GetLeaseByID(string(identity.Id))
105
+ if !exists {
106
+ t.Fatal("Lease should exist")
107
+ }
108
+ if len(entry.Lease.Alpn) != 2 {
109
+ t.Errorf("Expected 2 ALPNs, got %d", len(entry.Lease.Alpn))
110
+ }
111
+}
112
+
113
+func TestLeaseManager_EmptyNameAllowed(t *testing.T) {
114
+ lm := NewLeaseManager(30 * time.Second)
115
+ defer lm.Stop()
116
+
117
+ identity1 := &rdsec.Identity{
118
+ Id: "identity-1",
119
+ PublicKey: []byte("public-key-1"),
120
+ }
121
+
122
+ identity2 := &rdsec.Identity{
123
+ Id: "identity-2",
124
+ PublicKey: []byte("public-key-2"),
125
+ }
126
+
127
+ // Both leases with empty names should succeed
128
+ lease1 := &rdverb.Lease{
129
+ Identity: identity1,
130
+ Name: "",
131
+ Alpn: []string{"http/1.1"},
132
+ Expires: time.Now().Add(10 * time.Minute).Unix(),
133
+ }
134
+
135
+ lease2 := &rdverb.Lease{
136
+ Identity: identity2,
137
+ Name: "",
138
+ Alpn: []string{"http/1.1"},
139
+ Expires: time.Now().Add(10 * time.Minute).Unix(),
140
+ }
141
+
142
+ if !lm.UpdateLease(lease1, 1) {
143
+ t.Fatal("First lease with empty name should succeed")
144
+ }
145
+
146
+ if !lm.UpdateLease(lease2, 2) {
147
+ t.Fatal("Second lease with empty name should succeed (empty names don't conflict)")
148
+ }
149
+}
150
+
151
+func TestLeaseManager_UnnamedAllowed(t *testing.T) {
152
+ lm := NewLeaseManager(30 * time.Second)
153
+ defer lm.Stop()
154
+
155
+ identity1 := &rdsec.Identity{
156
+ Id: "identity-1",
157
+ PublicKey: []byte("public-key-1"),
158
+ }
159
+
160
+ identity2 := &rdsec.Identity{
161
+ Id: "identity-2",
162
+ PublicKey: []byte("public-key-2"),
163
+ }
164
+
165
+ // Both leases with "(unnamed)" should succeed
166
+ lease1 := &rdverb.Lease{
167
+ Identity: identity1,
168
+ Name: "(unnamed)",
169
+ Alpn: []string{"http/1.1"},
170
+ Expires: time.Now().Add(10 * time.Minute).Unix(),
171
+ }
172
+
173
+ lease2 := &rdverb.Lease{
174
+ Identity: identity2,
175
+ Name: "(unnamed)",
176
+ Alpn: []string{"http/1.1"},
177
+ Expires: time.Now().Add(10 * time.Minute).Unix(),
178
+ }
179
+
180
+ if !lm.UpdateLease(lease1, 1) {
181
+ t.Fatal("First lease with '(unnamed)' should succeed")
182
+ }
183
+
184
+ if !lm.UpdateLease(lease2, 2) {
185
+ t.Fatal("Second lease with '(unnamed)' should succeed (unnamed don't conflict)")
186
+ }
187
+}
188
+
189
+func TestLeaseManager_UnicodeNameConflict(t *testing.T) {
190
+ lm := NewLeaseManager(30 * time.Second)
191
+ defer lm.Stop()
192
+
193
+ identity1 := &rdsec.Identity{
194
+ Id: "identity-1",
195
+ PublicKey: []byte("public-key-1"),
196
+ }
197
+
198
+ identity2 := &rdsec.Identity{
199
+ Id: "identity-2",
200
+ PublicKey: []byte("public-key-2"),
201
+ }
202
+
203
+ // Lease with Korean name
204
+ lease1 := &rdverb.Lease{
205
+ Identity: identity1,
206
+ Name: "한글서비스",
207
+ Alpn: []string{"http/1.1"},
208
+ Expires: time.Now().Add(10 * time.Minute).Unix(),
209
+ }
210
+
211
+ lease2 := &rdverb.Lease{
212
+ Identity: identity2,
213
+ Name: "한글서비스", // Same Korean name
214
+ Alpn: []string{"http/1.1"},
215
+ Expires: time.Now().Add(10 * time.Minute).Unix(),
216
+ }
217
+
218
+ if !lm.UpdateLease(lease1, 1) {
219
+ t.Fatal("First lease with Korean name should succeed")
220
+ }
221
+
222
+ if lm.UpdateLease(lease2, 2) {
223
+ t.Fatal("Second lease with same Korean name should fail")
224
+ }
225
+}
sdk/sdk.go
+26
@@ -6,6 +6,7 @@ import (
6
"fmt"
7
"io"
8
"net"
9
+ "regexp"
10
"slices"
11
"sync"
12
"time"
@@ -26,6 +27,22 @@ func NewCredential() *cryptoops.Credential {
27
return cred
28
}
29
30
+// URL-safe name validation regex
31
+// Allows: Unicode letters (\p{L}), Unicode numbers (\p{N}), hyphen (-), underscore (_)
32
+// This includes Korean (한글), Japanese (日本語), Chinese (中文), Arabic (العربية), etc.
33
+var urlSafeNameRegex = regexp.MustCompile(`^[\p{L}\p{N}_-]+$`)
34
+
35
+// isURLSafeName checks if a name contains only URL-safe characters
36
+// Supports Unicode characters including Korean (한글), Japanese (日本語), Chinese (中文), etc.
37
+// Disallows: spaces, special characters like /, ?, &, =, %, etc.
38
+// Note: Browsers will automatically URL-encode non-ASCII characters (e.g., 한글 → %ED%95%9C%EA%B8%80)
39
+func isURLSafeName(name string) bool {
40
+ if name == "" {
41
+ return true // Empty name is allowed (will be treated as unnamed)
42
+ }
43
+ return urlSafeNameRegex.MatchString(name)
44
+}
45
+
46
func webSocketDialer() func(context.Context, string) (io.ReadWriteCloser, error) {
47
return func(ctx context.Context, url string) (io.ReadWriteCloser, error) {
48
wsConn, _, err := websocket.DefaultDialer.Dial(url, nil)
@@ -130,6 +147,7 @@ var (
147
ErrListenerExists = errors.New("listener already exists for this credential")
148
ErrRelayExists = errors.New("relay already exists")
149
ErrRelayNotFound = errors.New("relay not found")
150
+ ErrInvalidName = errors.New("lease name contains invalid characters (only alphanumeric, hyphen, underscore allowed)")
151
ErrFailedToCreateClient = errors.New("failed to create relay client")
152
)
153
@@ -265,6 +283,14 @@ func (g *RDClient) Listen(cred *cryptoops.Credential, name string, alpns []strin
283
Strs("alpns", alpns).
284
Msg("[SDK] Creating listener")
285
286
+ // Validate name is URL-safe
287
+ if !isURLSafeName(name) {
288
+ log.Error().
289
+ Str("name", name).
290
+ Msg("[SDK] Lease name contains invalid characters")
291
+ return nil, ErrInvalidName
292
+ }
293
+
294
g.mu.Lock()
295
defer g.mu.Unlock()
296
sdk/validation_test.go
new
+70
@@ -0,0 +1,70 @@
1
+package sdk
2
+
3
+import "testing"
4
+
5
+func TestIsURLSafeName(t *testing.T) {
6
+ tests := []struct {
7
+ name string
8
+ input string
9
+ expected bool
10
+ }{
11
+ // Valid names
12
+ {"empty string", "", true},
13
+ {"simple name", "my-service", true},
14
+ {"with underscore", "my_service", true},
15
+ {"with numbers", "service123", true},
16
+ {"mixed case", "MyService", true},
17
+ {"all hyphens", "my-cool-service", true},
18
+ {"all underscores", "my_cool_service", true},
19
+ {"alphanumeric only", "service", true},
20
+ {"numbers only", "12345", true},
21
+ {"korean", "한글서비스", true},
22
+ {"korean with hyphen", "한글-서비스", true},
23
+ {"korean with underscore", "한글_서비스", true},
24
+ {"mixed korean english", "MyService한글", true},
25
+ {"japanese", "日本語サービス", true},
26
+ {"chinese", "中文服务", true},
27
+ {"arabic", "خدمة", true},
28
+ {"mixed languages", "Service-서비스-サービス", true},
29
+ {"korean numbers", "서비스123", true},
30
+
31
+ // Invalid names
32
+ {"with space", "my service", false},
33
+ {"with slash", "my/service", false},
34
+ {"with dot", "my.service", false},
35
+ {"with colon", "my:service", false},
36
+ {"with question mark", "my?service", false},
37
+ {"with ampersand", "my&service", false},
38
+ {"with equals", "my=service", false},
39
+ {"with percent", "my%service", false},
40
+ {"with plus", "my+service", false},
41
+ {"with asterisk", "my*service", false},
42
+ {"with at", "my@service", false},
43
+ {"with hash", "my#service", false},
44
+ {"with exclamation", "my!service", false},
45
+ {"with parentheses", "my(service)", false},
46
+ {"with brackets", "my[service]", false},
47
+ {"with braces", "my{service}", false},
48
+ {"with semicolon", "my;service", false},
49
+ {"with comma", "my,service", false},
50
+ {"with quote", "my'service", false},
51
+ {"with double quote", "my\"service", false},
52
+ {"with backslash", "my\\service", false},
53
+ {"with pipe", "my|service", false},
54
+ {"with tilde", "my~service", false},
55
+ {"with backtick", "my`service", false},
56
+ {"with less than", "my<service", false},
57
+ {"with greater than", "my>service", false},
58
+ {"emoji", "my-service😀", false},
59
+ {"with space korean", "한글 서비스", false},
60
+ }
61
+
62
+ for _, tt := range tests {
63
+ t.Run(tt.name, func(t *testing.T) {
64
+ result := isURLSafeName(tt.input)
65
+ if result != tt.expected {
66
+ t.Errorf("isURLSafeName(%q) = %v, want %v", tt.input, result, tt.expected)
67
+ }
68
+ })
69
+ }
70
+}