sdk: add identity json flag

Kim committed Apr 2, 2026 at 13:48 UTC 2f280b606bed04c8476c57334c4d612faf885346
6 files changed +161 -3
cmd/demo-app/main.go
+5
@@ -35,6 +35,7 @@ type demoConfig struct {
35 discovery bool
36 banMITM bool
37 identityPath string
38 + identityJSON string
39 addr string
40 name string
41 desc string
@@ -52,6 +53,7 @@ func runTCPCommand(args []string) error {
53 utils.BoolFlagEnv(fs, &cfg.discovery, "discovery", true, "include public registry relays and enable discovery", "DISCOVERY")
54 utils.BoolFlagEnv(fs, &cfg.banMITM, "ban-mitm", false, "ban relay when the MITM self-probe detects TLS termination", "BAN_MITM")
55 utils.StringFlagEnv(fs, &cfg.identityPath, "identity-path", "identity.json", "identity json file path", "IDENTITY_PATH")
56 + utils.StringFlagEnv(fs, &cfg.identityJSON, "identity-json", "", "identity json payload; overrides --identity-path contents and is persisted there when both are set", "IDENTITY_JSON")
57 utils.StringFlag(fs, &cfg.addr, "addr", "127.0.0.1:8092", "local demo HTTP listen address (host:port or URL; disable if empty)")
58 utils.StringFlag(fs, &cfg.name, "name", "demo-app", "public hostname prefix (single DNS label)")
59 utils.StringFlag(fs, &cfg.desc, "description", "Portal demo connectivity app", "lease description")
@@ -90,6 +92,7 @@ func runUDPCommand(args []string) error {
92 utils.BoolFlagEnv(fs, &cfg.discovery, "discovery", true, "include public registry relays and enable discovery", "DISCOVERY")
93 utils.BoolFlagEnv(fs, &cfg.banMITM, "ban-mitm", false, "ban relay when the MITM self-probe detects TLS termination", "BAN_MITM")
94 utils.StringFlagEnv(fs, &cfg.identityPath, "identity-path", "identity.json", "identity json file path", "IDENTITY_PATH")
95 + utils.StringFlagEnv(fs, &cfg.identityJSON, "identity-json", "", "identity json payload; overrides --identity-path contents and is persisted there when both are set", "IDENTITY_JSON")
96 utils.StringFlag(fs, &cfg.name, "name", "demo-udp", "public hostname prefix (single DNS label)")
97 utils.StringFlag(fs, &cfg.desc, "description", "Portal demo UDP echo service", "lease description")
98 utils.StringFlag(fs, &cfg.tags, "tags", "demo,udp,echo", "comma-separated lease tags")
@@ -149,6 +152,7 @@ func runTCPDemo(ctx context.Context, cfg demoConfig) error {
152 exposure, err := sdk.Expose(ctx, sdk.ExposeConfig{
153 RelayURLs: utils.SplitCSV(cfg.relayURLs),
154 IdentityPath: cfg.identityPath,
155 + IdentityJSON: cfg.identityJSON,
156 Name: cfg.name,
157 BanMITM: cfg.banMITM,
158 Discovery: cfg.discovery,
@@ -190,6 +194,7 @@ func runUDPDemo(ctx context.Context, cfg demoConfig) error {
194 exposure, err := sdk.Expose(ctx, sdk.ExposeConfig{
195 RelayURLs: utils.SplitCSV(cfg.relayURLs),
196 IdentityPath: cfg.identityPath,
197 + IdentityJSON: cfg.identityJSON,
198 Name: cfg.name,
199 UDPEnabled: true,
200 BanMITM: cfg.banMITM,
cmd/portal-tunnel/main.go
+12
@@ -36,6 +36,7 @@ type exposeFlags struct {
36 discovery bool
37 banMITM bool
38 identityPath string
39 + identityJSON string
40 name string
41 desc string
42 tags string
@@ -56,6 +57,7 @@ func runExposeCommand(args []string) error {
57 utils.BoolFlag(fs, &flags.discovery, "discovery", true, "Include public registry relays and discover additional relay bootstraps")
58 utils.BoolFlagEnv(fs, &flags.banMITM, "ban-mitm", true, "Ban relay when the MITM self-probe detects TLS termination", "BAN_MITM")
59 utils.StringFlagEnv(fs, &flags.identityPath, "identity-path", "identity.json", "identity json file path", "IDENTITY_PATH")
60 + utils.StringFlagEnv(fs, &flags.identityJSON, "identity-json", "", "identity json payload; overrides --identity-path contents and is persisted there when both are set", "IDENTITY_JSON")
61 utils.StringFlag(fs, &flags.name, "name", "", "Public hostname prefix (single DNS label); auto-generated when omitted")
62 utils.StringFlag(fs, &flags.desc, "description", "", "Service description metadata")
63 utils.StringFlag(fs, &flags.tags, "tags", "", "Service tags metadata (comma-separated)")
@@ -90,6 +92,15 @@ func runExposeCommand(args []string) error {
92 printExposeUsage(os.Stderr)
93 return errors.New("--udp cannot be combined with --http-route")
94 }
95 + if flags.name == "" && strings.TrimSpace(flags.identityJSON) != "" {
96 + identity, err := utils.ParseIdentityJSON(flags.identityJSON)
97 + if err != nil {
98 + return fmt.Errorf("parse --identity-json: %w", err)
99 + }
100 + if strings.TrimSpace(identity.Name) != "" {
101 + flags.name = identity.Name
102 + }
103 + }
104 if flags.name == "" {
105 storedIdentity, err := utils.LoadIdentity(flags.identityPath)
106 if err == nil && strings.TrimSpace(storedIdentity.Name) != "" {
@@ -116,6 +127,7 @@ func runExposeCommand(args []string) error {
127 exposure, err := sdk.Expose(ctx, sdk.ExposeConfig{
128 RelayURLs: utils.SplitCSV(flags.relayCSV),
129 IdentityPath: flags.identityPath,
130 + IdentityJSON: flags.identityJSON,
131 Name: flags.name,
132 TargetAddr: flags.targetAddr,
133 UDPAddr: flags.udpAddr,
sdk/expose.go
+6 -1
@@ -47,6 +47,7 @@ type Exposure struct {
47 type ExposeConfig struct {
48 RelayURLs []string
49 IdentityPath string
50 + IdentityJSON string
51 Name string
52 TargetAddr string
53 UDPAddr string
@@ -65,7 +66,11 @@ func Expose(ctx context.Context, cfg ExposeConfig) (*Exposure, error) {
66 return nil, err
67 }
68
68 - identity, createdIdentity, err := utils.ResolveListenerIdentity(types.Identity{Name: cfg.Name}, cfg.IdentityPath)
69 + identity, createdIdentity, err := utils.ResolveListenerIdentity(
70 + types.Identity{Name: cfg.Name},
71 + cfg.IdentityPath,
72 + cfg.IdentityJSON,
73 + )
74 if err != nil {
75 return nil, fmt.Errorf("resolve identity: %w", err)
76 }
sdk/sdk_test.go
+99
@@ -268,6 +268,105 @@ func TestExposeLoadsPrivateKeyFromIdentityPath(t *testing.T) {
268 }
269 }
270
271 +func TestExposeLoadsIdentityFromJSON(t *testing.T) {
272 + privateKey := strings.Repeat("22", 32)
273 + identity, err := utils.ResolveSecp256k1Identity(privateKey)
274 + if err != nil {
275 + t.Fatalf("ResolveSecp256k1Identity() error = %v", err)
276 + }
277 + identity.Name = "demo-json"
278 +
279 + payload, err := json.Marshal(map[string]string{
280 + "name": identity.Name,
281 + "address": identity.Address,
282 + "public_key": identity.PublicKey,
283 + "private_key": identity.PrivateKey,
284 + })
285 + if err != nil {
286 + t.Fatalf("json.Marshal() error = %v", err)
287 + }
288 +
289 + challengeReqCh := make(chan types.RegisterChallengeRequest, 1)
290 + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
291 + switch r.URL.Path {
292 + case types.PathSDKDomain:
293 + writeSDKTestEnvelope(w, http.StatusOK, types.APIEnvelope[types.DomainResponse]{
294 + OK: true,
295 + Data: types.DomainResponse{
296 + ProtocolVersion: types.ProtocolVersion,
297 + },
298 + })
299 + case types.PathSDKRegisterChallenge:
300 + var challengeReq types.RegisterChallengeRequest
301 + if err := json.NewDecoder(r.Body).Decode(&challengeReq); err != nil {
302 + t.Fatalf("decode register challenge request: %v", err)
303 + }
304 + select {
305 + case challengeReqCh <- challengeReq:
306 + default:
307 + }
308 + writeSDKTestEnvelope(w, http.StatusCreated, types.APIEnvelope[types.RegisterChallengeResponse]{
309 + OK: true,
310 + Data: types.RegisterChallengeResponse{
311 + ChallengeID: "challenge-1",
312 + ExpiresAt: time.Now().Add(time.Minute).UTC(),
313 + SIWEMessage: mustSDKTestSIWEMessage(t, r, challengeReq.Identity.Address, "challenge-1"),
314 + },
315 + })
316 + case types.PathSDKRegister:
317 + writeSDKTestEnvelope(w, http.StatusCreated, types.APIEnvelope[types.RegisterResponse]{
318 + OK: true,
319 + Data: types.RegisterResponse{
320 + Identity: types.Identity{Name: identity.Name, Address: identity.Address},
321 + Hostname: "127.0.0.1",
322 + AccessToken: "jwt-register-json",
323 + },
324 + })
325 + case types.PathSDKConnect:
326 + writeSDKTestEnvelope(w, http.StatusForbidden, types.APIEnvelope[any]{
327 + OK: false,
328 + Error: &types.APIError{Code: types.APIErrorCodeUnauthorized, Message: "not used in test"},
329 + })
330 + case types.PathSDKRenew:
331 + writeSDKTestEnvelope(w, http.StatusOK, types.APIEnvelope[types.RenewResponse]{
332 + OK: true,
333 + Data: types.RenewResponse{Identity: types.Identity{Name: identity.Name, Address: identity.Address}, AccessToken: "jwt-renew-json"},
334 + })
335 + case types.PathSDKUnregister:
336 + writeSDKTestEnvelope(w, http.StatusOK, types.APIEnvelope[any]{OK: true})
337 + default:
338 + http.NotFound(w, r)
339 + }
340 + }))
341 + defer server.Close()
342 +
343 + exposure, err := Expose(context.Background(), ExposeConfig{
344 + RelayURLs: []string{server.URL},
345 + IdentityJSON: string(payload),
346 + })
347 + if err != nil {
348 + t.Fatalf("Expose() error = %v", err)
349 + }
350 + defer exposure.Close()
351 +
352 + var challengeReq types.RegisterChallengeRequest
353 + waitForSDKTest(t, func() bool {
354 + select {
355 + case challengeReq = <-challengeReqCh:
356 + return true
357 + default:
358 + return false
359 + }
360 + })
361 +
362 + if challengeReq.Identity.Name != identity.Name {
363 + t.Fatalf("register challenge Identity.Name = %q, want %q", challengeReq.Identity.Name, identity.Name)
364 + }
365 + if challengeReq.Identity.Address != identity.Address {
366 + t.Fatalf("register challenge Identity.Address = %q, want %q", challengeReq.Identity.Address, identity.Address)
367 + }
368 +}
369 +
370 func TestExposeGeneratesAddressWithoutPrivateKey(t *testing.T) {
371 challengeReqCh := make(chan types.RegisterChallengeRequest, 1)
372 var mu sync.RWMutex
types/types.go
+1 -1
@@ -1,7 +1,7 @@
1 package types
2
3 const (
4 - ReleaseVersion = "v2.1.0"
4 + ReleaseVersion = "v2.1.1"
5 ProtocolVersion = "4"
6 PortalRelayRegistryURL = "https://raw.githubusercontent.com/gosuda/portal/main/registry.json"
7
utils/identity.go
+38 -1
@@ -1,6 +1,7 @@
1 package utils
2
3 import (
4 + "encoding/json"
5 "errors"
6 "fmt"
7 "os"
@@ -130,6 +131,24 @@ func LoadIdentity(path string) (types.Identity, error) {
131 })
132 }
133
134 +func ParseIdentityJSON(raw string) (types.Identity, error) {
135 + raw = strings.TrimSpace(raw)
136 + if raw == "" {
137 + return types.Identity{}, errors.New("identity json is required")
138 + }
139 +
140 + var payload storedIdentity
141 + if err := json.Unmarshal([]byte(raw), &payload); err != nil {
142 + return types.Identity{}, fmt.Errorf("decode identity json: %w", err)
143 + }
144 + return NormalizeStoredIdentity(types.Identity{
145 + Name: payload.Name,
146 + Address: payload.Address,
147 + PublicKey: payload.PublicKey,
148 + PrivateKey: payload.PrivateKey,
149 + })
150 +}
151 +
152 func LoadOrCreateIdentity(path string, identity types.Identity) (types.Identity, bool, error) {
153 path = strings.TrimSpace(path)
154 if path == "" {
@@ -188,8 +207,26 @@ func LoadOrCreateIdentity(path string, identity types.Identity) (types.Identity,
207 return loaded, true, nil
208 }
209
191 -func ResolveListenerIdentity(identity types.Identity, identityPath string) (types.Identity, bool, error) {
210 +func ResolveListenerIdentity(identity types.Identity, identityPath, identityJSON string) (types.Identity, bool, error) {
211 identityPath = strings.TrimSpace(identityPath)
212 + identityJSON = strings.TrimSpace(identityJSON)
213 + if identityJSON != "" {
214 + provided, err := ParseIdentityJSON(identityJSON)
215 + if err != nil {
216 + return types.Identity{}, false, err
217 + }
218 + if identityPath != "" {
219 + if err := SaveIdentity(identityPath, provided); err != nil {
220 + return types.Identity{}, false, fmt.Errorf("persist identity: %w", err)
221 + }
222 + provided, err = LoadIdentity(identityPath)
223 + if err != nil {
224 + return types.Identity{}, false, fmt.Errorf("load identity: %w", err)
225 + }
226 + }
227 + resolved, err := ResolveLeaseIdentity(provided)
228 + return resolved, false, err
229 + }
230 if identityPath == "" {
231 resolved, err := ResolveLeaseIdentity(identity)
232 return resolved, false, err