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 {