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 +}