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