From 634ce9f7c81daa6a62160a6661eab8d13fc09bfb Mon Sep 17 00:00:00 2001 From: Matthias Hanel Date: Wed, 23 Sep 2020 01:54:07 -0400 Subject: [PATCH 1/2] [Adding] Accountz monitoring endpoint and INFO monitoring req subject Returned imports/exports are formated like jwt exports imports, even if they originating account is from config. Fixes #1604 Signed-off-by: Matthias Hanel --- server/accounts.go | 1 + server/events.go | 25 +++++++ server/events_test.go | 114 ++++++++++++++++++++++++++++- server/jwt_test.go | 1 + server/monitor.go | 161 +++++++++++++++++++++++++++++++++++++++++ server/monitor_test.go | 21 ++++++ server/route.go | 1 + server/server.go | 9 ++- 8 files changed, 328 insertions(+), 5 deletions(-) diff --git a/server/accounts.go b/server/accounts.go index e329541e804..83615457e8c 100644 --- a/server/accounts.go +++ b/server/accounts.go @@ -2618,6 +2618,7 @@ func (s *Server) updateAccountClaimsWithRefresh(a *Account, ac *jwt.AccountClaim a.jsLimits = nil } } + a.updated = time.Now() a.mu.Unlock() clients := gatherClients() diff --git a/server/events.go b/server/events.go index 71c558cb84b..97b3f9316e3 100644 --- a/server/events.go +++ b/server/events.go @@ -639,6 +639,10 @@ func (s *Server) initEventTracking() { optz := &LeafzEventOptions{} s.zReq(reply, msg, &optz.EventFilterOptions, optz, func() (interface{}, error) { return s.Leafz(&optz.LeafzOptions) }) }, + "ACCOUNTZ": func(sub *subscription, _ *client, subject, reply string, msg []byte) { + optz := &AccountzEventOptions{} + s.zReq(reply, msg, &optz.EventFilterOptions, optz, func() (interface{}, error) { return s.Accountz(&optz.AccountzOptions) }) + }, } for name, req := range monSrvc { subject = fmt.Sprintf(serverDirectReqSubj, s.info.ID, name) @@ -681,6 +685,16 @@ func (s *Server) initEventTracking() { } }) }, + "INFO": func(sub *subscription, _ *client, subject, reply string, msg []byte) { + optz := &AccInfoEventOptions{} + s.zReq(reply, msg, &optz.EventFilterOptions, optz, func() (interface{}, error) { + if acc, err := extractAccount(subject); err != nil { + return nil, err + } else { + return s.accountInfo(acc) + } + }) + }, "CONNS": s.connsRequest, } for name, req := range monAccSrvc { @@ -918,7 +932,12 @@ type EventFilterOptions struct { // StatszEventOptions are options passed to Statsz type StatszEventOptions struct { // No actual options yet + EventFilterOptions +} +// Options for account Info +type AccInfoEventOptions struct { + // No actual options yet EventFilterOptions } @@ -958,6 +977,12 @@ type LeafzEventOptions struct { EventFilterOptions } +// In the context of system events, AccountzEventOptions are options passed to Accountz +type AccountzEventOptions struct { + AccountzOptions + EventFilterOptions +} + // returns true if the request does NOT apply to this server and can be ignored. // DO NOT hold the server lock when func (s *Server) filterRequest(fOpts *EventFilterOptions) bool { diff --git a/server/events_test.go b/server/events_test.go index 3c204b0ba58..b9f85307446 100644 --- a/server/events_test.go +++ b/server/events_test.go @@ -1258,6 +1258,112 @@ func TestAccountReqMonitoring(t *testing.T) { } } +func TestAccountReqInfo(t *testing.T) { + s, opts := runTrustedServer(t) + defer s.Shutdown() + sacc, sakp := createAccount(s) + s.setSystemAccount(sacc) + // Let's create an account with service export. + akp, _ := nkeys.CreateAccount() + pub1, _ := akp.PublicKey() + nac1 := jwt.NewAccountClaims(pub1) + nac1.Exports.Add(&jwt.Export{Subject: "req.*", Type: jwt.Service}) + ajwt1, _ := nac1.Encode(oKp) + addAccountToMemResolver(s, pub1, ajwt1) + s.LookupAccount(pub1) + info1 := fmt.Sprintf(accReqSubj, pub1, "INFO") + // Now add an account with service imports. + akp2, _ := nkeys.CreateAccount() + pub2, _ := akp2.PublicKey() + nac2 := jwt.NewAccountClaims(pub2) + nac2.Imports.Add(&jwt.Import{Account: pub1, Subject: "req.1", Type: jwt.Service}) + ajwt2, _ := nac2.Encode(oKp) + addAccountToMemResolver(s, pub2, ajwt2) + s.LookupAccount(pub2) + info2 := fmt.Sprintf(accReqSubj, pub2, "INFO") + // Create system account connection to query + url := fmt.Sprintf("nats://%s:%d", opts.Host, opts.Port) + ncSys, err := nats.Connect(url, createUserCreds(t, s, sakp)) + if err != nil { + t.Fatalf("Error on connect: %v", err) + } + defer ncSys.Close() + checkCommon := func(info *AccountInfo, srv *ServerInfo, pub, jwt string) { + if info.Complete != true { + t.Fatalf("Unexpected value: %v", info.Complete) + } else if info.Expired != false { + t.Fatalf("Unexpected value: %v", info.Expired) + } else if info.JetStream != false { + t.Fatalf("Unexpected value: %v", info.JetStream) + } else if info.ClientCnt != 0 { + t.Fatalf("Unexpected value: %v", info.ClientCnt) + } else if info.AccountName != pub { + t.Fatalf("Unexpected value: %v", info.AccountName) + } else if info.LeafCnt != 0 { + t.Fatalf("Unexpected value: %v", info.LeafCnt) + } else if info.Jwt != jwt { + t.Fatalf("Unexpected value: %v", info.Jwt) + } else if srv.Cluster != "abc" { + t.Fatalf("Unexpected value: %v", srv.Cluster) + } else if srv.Name != s.Name() { + t.Fatalf("Unexpected value: %v", srv.Name) + } else if srv.Host != opts.Host { + t.Fatalf("Unexpected value: %v", srv.Host) + } else if srv.Seq < 1 { + t.Fatalf("Unexpected value: %v", srv.Seq) + } + } + info := AccountInfo{} + srv := ServerInfo{} + msg := struct { + Data *AccountInfo `json:"data"` + Srv *ServerInfo `json:"server"` + }{ + &info, + &srv, + } + if resp, err := ncSys.Request(info1, nil, time.Second); err != nil { + t.Fatalf("Error on request: %v", err) + } else if err := json.Unmarshal(resp.Data, &msg); err != nil { + t.Fatalf("Unmarshalling failed: %v", err) + } else if len(info.Exps) != 1 { + t.Fatalf("Unexpected value: %v", info.Exps) + } else if len(info.Imps) != 0 { + t.Fatalf("Unexpected value: %v", info.Imps) + } else if info.Exps[0].Subject != "req.*" { + t.Fatalf("Unexpected value: %v", info.Exps) + } else if info.Exps[0].Type != jwt.Service { + t.Fatalf("Unexpected value: %v", info.Exps) + } else if info.Exps[0].ResponseType != jwt.ResponseTypeSingleton { + t.Fatalf("Unexpected value: %v", info.Exps) + } else if info.SubCnt != 0 { + t.Fatalf("Unexpected value: %v", info.SubCnt) + } else { + checkCommon(&info, &srv, pub1, ajwt1) + } + info = AccountInfo{} + srv = ServerInfo{} + if resp, err := ncSys.Request(info2, nil, time.Second); err != nil { + t.Fatalf("Error on request: %v", err) + } else if err := json.Unmarshal(resp.Data, &msg); err != nil { + t.Fatalf("Unmarshalling failed: %v", err) + } else if len(info.Exps) != 0 { + t.Fatalf("Unexpected value: %v", info.Exps) + } else if len(info.Imps) != 1 { + t.Fatalf("Unexpected value: %v", info.Imps) + } else if info.Imps[0].Subject != "req.1" { + t.Fatalf("Unexpected value: %v", info.Exps) + } else if info.Imps[0].Type != jwt.Service { + t.Fatalf("Unexpected value: %v", info.Exps) + } else if info.Imps[0].Account != pub1 { + t.Fatalf("Unexpected value: %v", info.Exps) + } else if info.SubCnt != 1 { + t.Fatalf("Unexpected value: %v", info.SubCnt) + } else { + checkCommon(&info, &srv, pub2, ajwt2) + } +} + func TestAccountClaimsUpdatesWithServiceImports(t *testing.T) { s, opts := runTrustedServer(t) defer s.Shutdown() @@ -1453,7 +1559,7 @@ func TestSystemAccountWithGateways(t *testing.T) { // If this tests fails with wrong number after 10 seconds we may have // added a new inititial subscription for the eventing system. - checkExpectedSubs(t, 30, sa) + checkExpectedSubs(t, 33, sa) // Create a client on B and see if we receive the event urlb := fmt.Sprintf("nats://%s:%d", ob.Host, ob.Port) @@ -1775,7 +1881,7 @@ func TestServerEventsPingMonitorz(t *testing.T) { sa, _, sb, optsB, akp := runTrustedCluster(t) defer sa.Shutdown() defer sb.Shutdown() - + sysAcc, _ := akp.PublicKey() url := fmt.Sprintf("nats://%s:%d", optsB.Host, optsB.Port) nc, err := nats.Connect(url, createUserCreds(t, sb, akp)) if err != nil { @@ -1814,6 +1920,8 @@ func TestServerEventsPingMonitorz(t *testing.T) { []string{"now", "outbound_gateways", "inbound_gateways"}}, {"LEAFZ", &LeafzOptions{}, &Leafz{}, []string{"now", "leafs"}}, + {"ACCOUNTZ", &AccountzOptions{}, &Accountz{}, + []string{"now", "accounts"}}, {"SUBSZ", &SubszOptions{Limit: 5}, &Subsz{}, []string{"num_subscriptions", "num_cache"}}, @@ -1825,6 +1933,8 @@ func TestServerEventsPingMonitorz(t *testing.T) { []string{"now", "outbound_gateways", "inbound_gateways"}}, {"LEAFZ", &LeafzOptions{Subscriptions: true}, &Leafz{}, []string{"now", "leafs"}}, + {"ACCOUNTZ", &AccountzOptions{Account: sysAcc}, &Accountz{}, + []string{"now", "account_detail"}}, {"ROUTEZ", json.RawMessage(`{"cluster":""}`), &Routez{}, []string{"now", "routes"}}, diff --git a/server/jwt_test.go b/server/jwt_test.go index 8424095d9a8..2c740295e98 100644 --- a/server/jwt_test.go +++ b/server/jwt_test.go @@ -2994,6 +2994,7 @@ func TestJWTAccountLimitsMaxConnsAfterExpired(t *testing.T) { acc, _ := s.LookupAccount(fooPub) acc.mu.Lock() acc.expired = true + acc.updated = time.Now().Add(-2 * time.Second) // work around updating to quickly acc.mu.Unlock() // Now update with new expiration and max connections lowered to 2 diff --git a/server/monitor.go b/server/monitor.go index 279c761241b..7b8089cb4f2 100644 --- a/server/monitor.go +++ b/server/monitor.go @@ -27,6 +27,7 @@ import ( "sync/atomic" "time" + "github.com/nats-io/jwt/v2" "github.com/nats-io/nats-server/v2/server/pse" ) @@ -1144,6 +1145,7 @@ func (s *Server) HandleRoot(w http.ResponseWriter, r *http.Request) { gatewayz
leafz
subsz
+ accountz

help @@ -1154,6 +1156,7 @@ func (s *Server) HandleRoot(w http.ResponseWriter, r *http.Request) { s.basePath(GatewayzPath), s.basePath(LeafzPath), s.basePath(SubszPath), + s.basePath(AccountzPath), ) } @@ -1910,3 +1913,161 @@ func (reason ClosedState) String() string { return "Unknown State" } + +// LeafzOptions are options passed to Leafz +type AccountzOptions struct { + // Account indicates that Accountz will return details for the account + Account string `json:"account"` +} + +type ExtImport struct { + jwt.Import + Invalid bool +} + +type ExtExport struct { + jwt.Export + ApprovedAccounts []string `json:"approved_accounts"` +} + +type AccountInfo struct { + AccountName string `json:"account_name"` + LastUpdate time.Time `json:"update_time,omitempty"` + Expired bool `json:"expired"` + Complete bool `json:"complete"` + JetStream bool `json:"jetstream_enabled"` + LeafCnt int `json:"leafnode_connections"` + ClientCnt int `json:"client_connections"` + SubCnt uint32 `json:"subscriptions"` + Exps []ExtExport `json:"exports"` + Imps []ExtImport `json:"imports"` + Jwt string `json:"jwt,omitempty"` + Claim *jwt.AccountClaims `json:"decoded_jwt,omitempty"` +} + +type Accountz struct { + ID string `json:"server_id"` + Now time.Time `json:"now"` + Accounts []string `json:"accounts,omitempty"` + Account *AccountInfo `json:"account_detail,omitempty"` +} + +// HandleAccountz process HTTP requests for account information. +func (s *Server) HandleAccountz(w http.ResponseWriter, r *http.Request) { + s.mu.Lock() + s.httpReqStats[AccountzPath]++ + s.mu.Unlock() + if l, err := s.Accountz(&AccountzOptions{r.URL.Query().Get("acc")}); err != nil { + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte(err.Error())) + } else if b, err := json.MarshalIndent(l, "", " "); err != nil { + s.Errorf("Error marshaling response to %s request: %v", AccountzPath, err) + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte(err.Error())) + } else { + ResponseHandler(w, r, b) // Handle response + } +} + +func (s *Server) Accountz(optz *AccountzOptions) (*Accountz, error) { + a := &Accountz{ + ID: s.ID(), + Now: time.Now(), + } + if optz.Account == "" { + a.Accounts = []string{} + s.accounts.Range(func(key, value interface{}) bool { + a.Accounts = append(a.Accounts, key.(string)) + return true + }) + return a, nil + } else if aInfo, err := s.accountInfo(optz.Account); err != nil { + return nil, err + } else { + a.Account = aInfo + return a, nil + } +} + +func (s *Server) accountInfo(accName string) (*AccountInfo, error) { + var a *Account + if v, ok := s.accounts.Load(accName); !ok { + return nil, fmt.Errorf("Account %s does not exist", accName) + } else { + a = v.(*Account) + } + a.mu.RLock() + defer a.mu.RUnlock() + claim, _ := jwt.DecodeAccountClaims(a.claimJWT) // ignore error + exports := []ExtExport{} + for k, v := range a.exports.services { + e := ExtExport{ + Export: jwt.Export{ + Subject: jwt.Subject(k), + Type: jwt.Service, + TokenReq: v.tokenReq, + ResponseType: jwt.ResponseType(v.respType.String()), + }, + ApprovedAccounts: []string{}, + } + for name := range v.approved { + e.ApprovedAccounts = append(e.ApprovedAccounts, name) + } + exports = append(exports, e) + } + for k, v := range a.exports.streams { + e := ExtExport{ + Export: jwt.Export{ + Subject: jwt.Subject(k), + Type: jwt.Stream, + TokenReq: v.tokenReq, + }, + ApprovedAccounts: []string{}, + } + for name := range v.approved { + e.ApprovedAccounts = append(e.ApprovedAccounts, name) + } + exports = append(exports, e) + } + imports := []ExtImport{} + for _, v := range a.imports.streams { + to := "" + if v.prefix != "" { + to = v.prefix + "." + v.from + } + imports = append(imports, ExtImport{ + Import: jwt.Import{ + Subject: jwt.Subject(v.from), + Account: v.acc.Name, + Type: jwt.Stream, + To: jwt.Subject(to), + }, + Invalid: v.invalid, + }) + } + for _, v := range a.imports.services { + imports = append(imports, ExtImport{ + Import: jwt.Import{ + Subject: jwt.Subject(v.from), + Account: v.acc.Name, + Type: jwt.Service, + To: jwt.Subject(v.to), + }, + Invalid: v.invalid, + }) + } + return &AccountInfo{ + accName, + a.updated, + a.expired, + !a.incomplete, + a.js != nil, + a.numLocalLeafNodes(), + a.numLocalConnections(), + a.sl.Count(), + exports, + imports, + a.claimJWT, + claim, + }, nil +} diff --git a/server/monitor_test.go b/server/monitor_test.go index 2a5555c0484..0f78e9ae712 100644 --- a/server/monitor_test.go +++ b/server/monitor_test.go @@ -3646,3 +3646,24 @@ func TestMonitorLeafz(t *testing.T) { } } } + +func TestMonitorAccountz(t *testing.T) { + s := RunServer(DefaultMonitorOptions()) + defer s.Shutdown() + body := string(readBody(t, fmt.Sprintf("http://127.0.0.1:%d/accountz", s.MonitorAddr().Port))) + if !strings.Contains(body, `$G`) { + t.Fatalf("Body missing value. Contains: %s", body) + } else if !strings.Contains(body, `$SYS`) { + t.Fatalf("Body missing value. Contains: %s", body) + } else if !strings.Contains(body, `"accounts": [`) { + t.Fatalf("Body missing value. Contains: %s", body) + } + body = string(readBody(t, fmt.Sprintf("http://127.0.0.1:%d/accountz?acc=$SYS", s.MonitorAddr().Port))) + if !strings.Contains(body, `"account_detail": {`) { + t.Fatalf("Body missing value. Contains: %s", body) + } else if !strings.Contains(body, `"account_name": "$SYS",`) { + t.Fatalf("Body missing value. Contains: %s", body) + } else if !strings.Contains(body, `"subscriptions": 32,`) { + t.Fatalf("Body missing value. Contains: %s", body) + } +} diff --git a/server/route.go b/server/route.go index 8609f38fadb..fcd3014b9ec 100644 --- a/server/route.go +++ b/server/route.go @@ -1028,6 +1028,7 @@ func (c *client) processRemoteSub(argo []byte, hasOrigin bool) (err error) { if acc, isNew = srv.LookupOrRegisterAccount(accountName); isNew && expire { acc.mu.Lock() acc.expired = true + acc.incomplete = true acc.mu.Unlock() } } diff --git a/server/server.go b/server/server.go index c6ddb8195c8..fa6b77e84e5 100644 --- a/server/server.go +++ b/server/server.go @@ -640,7 +640,7 @@ func (s *Server) configureAccounts() error { acc.ic.acc = acc acc.addAllServiceImportSubs() } - + acc.updated = time.Now() return true }) @@ -1157,6 +1157,7 @@ func (s *Server) registerAccountNoLock(acc *Account) *Account { acc.lqws = make(map[string]int32) } acc.srv = s + acc.updated = time.Now() acc.mu.Unlock() s.accounts.Store(acc.Name, acc) s.tmpAccounts.Delete(acc.Name) @@ -1205,7 +1206,7 @@ func (s *Server) LookupAccount(name string) (*Account, error) { // Lock MUST NOT be held upon entry. func (s *Server) updateAccount(acc *Account) error { // TODO(dlc) - Make configurable - if time.Since(acc.updated) < time.Second { + if !acc.incomplete && time.Since(acc.updated) < time.Second { s.Debugf("Requested account update for [%s] ignored, too soon", acc.Name) return ErrAccountResolverUpdateTooSoon } @@ -1228,7 +1229,6 @@ func (s *Server) updateAccountWithClaimJWT(acc *Account, claimJWT string) error } accClaims, _, err := s.verifyAccountClaims(claimJWT) if err == nil && accClaims != nil { - acc.updated = time.Now() acc.mu.Lock() if acc.Issuer == "" { acc.Issuer = accClaims.Issuer @@ -1908,6 +1908,7 @@ const ( LeafzPath = "/leafz" SubszPath = "/subsz" StackszPath = "/stacksz" + AccountzPath = "/accountz" ) func (s *Server) basePath(p string) string { @@ -1985,6 +1986,8 @@ func (s *Server) startMonitoring(secure bool) error { mux.HandleFunc(s.basePath("/subscriptionsz"), s.HandleSubsz) // Stacksz mux.HandleFunc(s.basePath(StackszPath), s.HandleStacksz) + // Accountz + mux.HandleFunc(s.basePath(AccountzPath), s.HandleAccountz) // Do not set a WriteTimeout because it could cause cURL/browser // to return empty response or unable to display page if the From 7a8a7a72346a532a95ec613a3b5d6b1c0a5aa441 Mon Sep 17 00:00:00 2001 From: Matthias Hanel Date: Wed, 23 Sep 2020 18:29:44 -0400 Subject: [PATCH 2/2] Incorporating review comments --- server/events_test.go | 40 ++++++++++++++++++++-------------------- server/monitor.go | 8 ++++---- 2 files changed, 24 insertions(+), 24 deletions(-) diff --git a/server/events_test.go b/server/events_test.go index b9f85307446..ced31d7a1f5 100644 --- a/server/events_test.go +++ b/server/events_test.go @@ -1326,16 +1326,16 @@ func TestAccountReqInfo(t *testing.T) { t.Fatalf("Error on request: %v", err) } else if err := json.Unmarshal(resp.Data, &msg); err != nil { t.Fatalf("Unmarshalling failed: %v", err) - } else if len(info.Exps) != 1 { - t.Fatalf("Unexpected value: %v", info.Exps) - } else if len(info.Imps) != 0 { - t.Fatalf("Unexpected value: %v", info.Imps) - } else if info.Exps[0].Subject != "req.*" { - t.Fatalf("Unexpected value: %v", info.Exps) - } else if info.Exps[0].Type != jwt.Service { - t.Fatalf("Unexpected value: %v", info.Exps) - } else if info.Exps[0].ResponseType != jwt.ResponseTypeSingleton { - t.Fatalf("Unexpected value: %v", info.Exps) + } else if len(info.Exports) != 1 { + t.Fatalf("Unexpected value: %v", info.Exports) + } else if len(info.Imports) != 0 { + t.Fatalf("Unexpected value: %v", info.Imports) + } else if info.Exports[0].Subject != "req.*" { + t.Fatalf("Unexpected value: %v", info.Exports) + } else if info.Exports[0].Type != jwt.Service { + t.Fatalf("Unexpected value: %v", info.Exports) + } else if info.Exports[0].ResponseType != jwt.ResponseTypeSingleton { + t.Fatalf("Unexpected value: %v", info.Exports) } else if info.SubCnt != 0 { t.Fatalf("Unexpected value: %v", info.SubCnt) } else { @@ -1347,16 +1347,16 @@ func TestAccountReqInfo(t *testing.T) { t.Fatalf("Error on request: %v", err) } else if err := json.Unmarshal(resp.Data, &msg); err != nil { t.Fatalf("Unmarshalling failed: %v", err) - } else if len(info.Exps) != 0 { - t.Fatalf("Unexpected value: %v", info.Exps) - } else if len(info.Imps) != 1 { - t.Fatalf("Unexpected value: %v", info.Imps) - } else if info.Imps[0].Subject != "req.1" { - t.Fatalf("Unexpected value: %v", info.Exps) - } else if info.Imps[0].Type != jwt.Service { - t.Fatalf("Unexpected value: %v", info.Exps) - } else if info.Imps[0].Account != pub1 { - t.Fatalf("Unexpected value: %v", info.Exps) + } else if len(info.Exports) != 0 { + t.Fatalf("Unexpected value: %v", info.Exports) + } else if len(info.Imports) != 1 { + t.Fatalf("Unexpected value: %v", info.Imports) + } else if info.Imports[0].Subject != "req.1" { + t.Fatalf("Unexpected value: %v", info.Exports) + } else if info.Imports[0].Type != jwt.Service { + t.Fatalf("Unexpected value: %v", info.Exports) + } else if info.Imports[0].Account != pub1 { + t.Fatalf("Unexpected value: %v", info.Exports) } else if info.SubCnt != 1 { t.Fatalf("Unexpected value: %v", info.SubCnt) } else { diff --git a/server/monitor.go b/server/monitor.go index 7b8089cb4f2..4401cc26678 100644 --- a/server/monitor.go +++ b/server/monitor.go @@ -1922,12 +1922,12 @@ type AccountzOptions struct { type ExtImport struct { jwt.Import - Invalid bool + Invalid bool `json:"invalid"` } type ExtExport struct { jwt.Export - ApprovedAccounts []string `json:"approved_accounts"` + ApprovedAccounts []string `json:"approved_accounts,omitempty"` } type AccountInfo struct { @@ -1939,8 +1939,8 @@ type AccountInfo struct { LeafCnt int `json:"leafnode_connections"` ClientCnt int `json:"client_connections"` SubCnt uint32 `json:"subscriptions"` - Exps []ExtExport `json:"exports"` - Imps []ExtImport `json:"imports"` + Exports []ExtExport `json:"exports"` + Imports []ExtImport `json:"imports"` Jwt string `json:"jwt,omitempty"` Claim *jwt.AccountClaims `json:"decoded_jwt,omitempty"` }