feat: add ENS status handling and integrate with API responses
Kim committed
May 14, 2026 at 15:52 UTC
f7c35905f69e1c52be4cfbca8e951cbe9f0ce4fc
5 files changed
+121
-11
frontend/src/components/Header.tsx
+38
-1
@@ -1,8 +1,10 @@
1
-import { useState } from "react";
1
+import { useEffect, useState } from "react";
2
import { Loader2, LogOut, Wallet } from "lucide-react";
3
import { Button } from "@/components/ui/button";
4
import { ThemeToggleButton } from "@/components/ThemeToggleButton";
5
import { useAuth } from "@/hooks/useAuth";
6
+import { apiClient } from "@/lib/apiClient";
7
+import { API_PATHS } from "@/lib/apiPaths";
8
import {
9
Tooltip,
10
TooltipContent,
@@ -18,6 +20,12 @@ interface HeaderProps {
20
showQuickStartLink?: boolean;
21
}
22
23
+interface DomainStatusResponse {
24
+ ens?: {
25
+ verified?: boolean;
26
+ };
27
+}
28
+
29
const repoURL = "https://github.com/gosuda/portal-tunnel";
30
31
function formatWalletAddress(address: string): string {
@@ -35,6 +43,7 @@ export function Header({
43
showQuickStartLink = true,
44
}: HeaderProps) {
45
const releaseVersion = getReleaseVersion();
46
+ const [ensVerified, setENSVerified] = useState(false);
47
const {
48
isAuthenticated,
49
isLoading,
@@ -67,6 +76,29 @@ export function Header({
76
isAuthenticated && walletAddress ? walletAddress : "Connect wallet"
77
);
78
79
+ useEffect(() => {
80
+ let cancelled = false;
81
+
82
+ void (async () => {
83
+ try {
84
+ const status = await apiClient.get<DomainStatusResponse>(
85
+ API_PATHS.sdk.domain
86
+ );
87
+ if (!cancelled) {
88
+ setENSVerified(status?.ens?.verified === true);
89
+ }
90
+ } catch {
91
+ if (!cancelled) {
92
+ setENSVerified(false);
93
+ }
94
+ }
95
+ })();
96
+
97
+ return () => {
98
+ cancelled = true;
99
+ };
100
+ }, []);
101
+
102
return (
103
<header className="flex flex-wrap items-center justify-between gap-x-4 gap-y-3 py-2 lg:flex-nowrap">
104
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-x-4 gap-y-2 text-foreground sm:gap-x-6 lg:gap-x-7">
@@ -96,6 +128,11 @@ export function Header({
128
{releaseVersion}
129
</span>
130
)}
131
+ {ensVerified && (
132
+ <span className="inline-flex h-6 items-center rounded-full bg-primary/12 px-2.5 text-xs font-semibold text-primary ring-1 ring-primary/20">
133
+ ENS verified
134
+ </span>
135
+ )}
136
</div>
137
</div>
138
</div>
portal/acme/acme.go
+67
-5
@@ -27,6 +27,7 @@ import (
27
28
"github.com/gosuda/portal-tunnel/v2/portal/identity"
29
"github.com/gosuda/portal-tunnel/v2/portal/keyless"
30
+ "github.com/gosuda/portal-tunnel/v2/types"
31
"github.com/gosuda/portal-tunnel/v2/utils"
32
)
33
@@ -70,6 +71,8 @@ type Manager struct {
71
stopOnce sync.Once
72
dnssecLogOnce sync.Once
73
ensLogOnce sync.Once
74
+ ensStatusMu sync.RWMutex
75
+ ensStatus types.ENSStatus
76
trackedMu sync.Mutex
77
echMu sync.Mutex
78
echRecords map[string]HTTPSRecord
@@ -178,14 +181,16 @@ func NewManager(cfg Config) (*Manager, error) {
181
}
182
if utils.IsLocalRelayHost(cfg.BaseDomain) {
183
return &Manager{
181
- cfg: cfg,
182
- stopCh: make(chan struct{}),
184
+ cfg: cfg,
185
+ stopCh: make(chan struct{}),
186
+ ensStatus: newENSStatus(cfg, nil),
187
}, nil
188
}
189
190
manager := &Manager{
187
- cfg: cfg,
188
- stopCh: make(chan struct{}),
191
+ cfg: cfg,
192
+ stopCh: make(chan struct{}),
193
+ ensStatus: newENSStatus(cfg, nil),
194
}
195
196
acmeDNS, err := NewDNSProvider(cfg.DNSProvider, cfg)
@@ -201,6 +206,58 @@ func NewManager(cfg Config) (*Manager, error) {
206
return manager, nil
207
}
208
209
+func newENSStatus(cfg Config, dns DNSProvider) types.ENSStatus {
210
+ provider := strings.TrimSpace(cfg.DNSProvider)
211
+ if dns != nil {
212
+ provider = dns.Name()
213
+ }
214
+ return types.ENSStatus{
215
+ Enabled: cfg.ENSGaslessEnabled && !utils.IsLocalRelayHost(cfg.BaseDomain),
216
+ Provider: provider,
217
+ Address: strings.TrimSpace(cfg.ENSGaslessAddress),
218
+ }
219
+}
220
+
221
+func (m *Manager) ENSStatus() types.ENSStatus {
222
+ if m == nil {
223
+ return types.ENSStatus{}
224
+ }
225
+ m.ensStatusMu.RLock()
226
+ status := m.ensStatus
227
+ m.ensStatusMu.RUnlock()
228
+ if status.Provider == "" && m.dns != nil {
229
+ status.Provider = m.dns.Name()
230
+ }
231
+ return status
232
+}
233
+
234
+func (m *Manager) setENSStatus(state, record, message string, syncErr error) {
235
+ if m == nil {
236
+ return
237
+ }
238
+ status := newENSStatus(m.cfg, m.dns)
239
+ status.DNSSECState = strings.TrimSpace(state)
240
+ status.DSRecord = strings.TrimSpace(record)
241
+ status.Message = strings.TrimSpace(message)
242
+ status.Verified = syncErr == nil && ensDNSSECVerified(status.DNSSECState)
243
+ if syncErr != nil {
244
+ status.LastError = syncErr.Error()
245
+ }
246
+
247
+ m.ensStatusMu.Lock()
248
+ m.ensStatus = status
249
+ m.ensStatusMu.Unlock()
250
+}
251
+
252
+func ensDNSSECVerified(state string) bool {
253
+ switch strings.ToLower(strings.TrimSpace(state)) {
254
+ case "active", "on", "signing", "transfer":
255
+ return true
256
+ default:
257
+ return false
258
+ }
259
+}
260
+
261
func (m *Manager) EnsureCertificate(ctx context.Context) (string, string, error) {
262
if m == nil {
263
return "", "", errors.New("acme manager is nil")
@@ -577,6 +634,7 @@ func (m *Manager) syncENSGasless(ctx context.Context) error {
634
635
state, dsRecord, message, err := m.dns.EnsureDNSSEC(ctx, m.cfg.BaseDomain)
636
if err != nil {
637
+ m.setENSStatus("", "", "", err)
638
return fmt.Errorf("ensure dnssec: %w", err)
639
}
640
m.dnssecLogOnce.Do(func() {
@@ -594,11 +652,15 @@ func (m *Manager) syncENSGasless(ctx context.Context) error {
652
})
653
654
if err := m.SyncENSGaslessHostname(ctx, m.cfg.BaseDomain, m.cfg.ENSGaslessAddress); err != nil {
597
- return fmt.Errorf("ensure ens gasless txt: %w", err)
655
+ err = fmt.Errorf("ensure ens gasless txt: %w", err)
656
+ m.setENSStatus(state, dsRecord, message, err)
657
+ return err
658
}
659
if err := m.syncTrackedENSGaslessHostARecords(ctx); err != nil {
660
+ m.setENSStatus(state, dsRecord, message, err)
661
return err
662
}
663
+ m.setENSStatus(state, dsRecord, message, nil)
664
m.ensLogOnce.Do(func() {
665
log.Info().
666
Str("provider", m.dns.Name()).
portal/api_server.go
+1
@@ -250,6 +250,7 @@ func (s *Server) handleDomain(w http.ResponseWriter, r *http.Request) {
250
utils.WriteAPIData(w, http.StatusOK, types.DomainResponse{
251
ProtocolVersion: types.SDKVersion,
252
ReleaseVersion: types.ReleaseVersion,
253
+ ENS: s.acmeManager.ENSStatus(),
254
})
255
}
256
registry.json
+1
-3
@@ -3,8 +3,6 @@
3
"https://portal.thumbgo.kr/",
4
"https://portal.rabbitson87.dev/",
5
"https://s-h.day/",
6
- "https://portal.dawnfullstack.com/",
7
- "https://portal.damn.it.com/",
8
- "https://kakashit.org"
6
+ "https://portal.dawnfullstack.com/"
7
]
8
}
types/api.go
+14
-2
@@ -184,8 +184,20 @@ func HopRouteBytes(method string, route HopRoute) ([]byte, error) {
184
}
185
186
type DomainResponse struct {
187
- ProtocolVersion string `json:"protocol_version"`
188
- ReleaseVersion string `json:"release_version"`
187
+ ProtocolVersion string `json:"protocol_version"`
188
+ ReleaseVersion string `json:"release_version"`
189
+ ENS ENSStatus `json:"ens"`
190
+}
191
+
192
+type ENSStatus struct {
193
+ Enabled bool `json:"enabled"`
194
+ Verified bool `json:"verified"`
195
+ Provider string `json:"provider,omitempty"`
196
+ Address string `json:"address,omitempty"`
197
+ DNSSECState string `json:"dnssec_state,omitempty"`
198
+ DSRecord string `json:"ds_record,omitempty"`
199
+ Message string `json:"message,omitempty"`
200
+ LastError string `json:"last_error,omitempty"`
201
}
202
203
type TunnelStatusResponse struct {