diff --git a/core/config/system.go b/core/config/system.go index ead7360bd..d08aa97ee 100755 --- a/core/config/system.go +++ b/core/config/system.go @@ -289,8 +289,9 @@ type ConfigsConfig struct { ValidConfigsExtensions []string `config:"valid_config_extensions"` TLSConfig TLSConfig `config:"tls"` //server or client's certs ManagerConfig struct { - LocalConfigsRepoPath string `config:"local_configs_repo_path"` - BasicAuth BasicAuth `config:"basic_auth"` + LocalConfigsRepoPath string `config:"local_configs_repo_path"` + BasicAuth BasicAuth `config:"basic_auth"` + AccessToken ucfg.SecretString `config:"access_token" yaml:"access_token"` } `config:"manager"` AlwaysRegisterAfterRestart bool `config:"always_register_after_restart"` AllowGeneratedMetricsTasks bool `config:"allow_generated_metrics_tasks"` diff --git a/core/credential/access_token_test.go b/core/credential/access_token_test.go new file mode 100644 index 000000000..92a3f8734 --- /dev/null +++ b/core/credential/access_token_test.go @@ -0,0 +1,29 @@ +package credential + +import "testing" + +func TestEncodeDecodeAccessToken(t *testing.T) { + cred := &Credential{ + Name: "agent-token", + Type: AccessToken, + Payload: map[string]interface{}{ + AccessToken: map[string]interface{}{ + "access_token": "mock-token", + }, + }, + } + cred.SetSecret([]byte("12345678901234567890123456789012")) + + if err := cred.Encode(); err != nil { + t.Fatalf("Encode() returned error: %v", err) + } + + token, err := cred.DecodeAccessToken() + if err != nil { + t.Fatalf("DecodeAccessToken() returned error: %v", err) + } + + if got := token.AccessToken.Get(); got != "mock-token" { + t.Fatalf("expected access token %q, got %q", "mock-token", got) + } +} diff --git a/core/credential/credential.go b/core/credential/credential.go index 62e17a4e6..6573e13a0 100644 --- a/core/credential/credential.go +++ b/core/credential/credential.go @@ -31,14 +31,15 @@ import ( "fmt" "infini.sh/framework/core/model" "infini.sh/framework/core/orm" + "infini.sh/framework/lib/go-ucfg" ) type Credential struct { orm.ORMObjectBase - Name string `json:"name" elastic_mapping:"name:{type:keyword,copy_to:search_text}"` - Type string `json:"type" elastic_mapping:"type:{type:keyword}"` - Tags []string `json:"tags" elastic_mapping:"category:{type:keyword,copy_to:search_text}"` - Payload map[string]interface{} `json:"payload" elastic_mapping:"payload:{type:object,enabled:false}"` + Name string `json:"name" elastic_mapping:"name:{type:keyword,copy_to:search_text}"` + Type CredentialType `json:"type" elastic_mapping:"type:{type:keyword}"` + Tags []string `json:"tags" elastic_mapping:"category:{type:keyword,copy_to:search_text}"` + Payload map[CredentialType]interface{} `json:"payload" elastic_mapping:"payload:{type:object,enabled:false}"` Encrypt struct { Type string `json:"type"` Params map[string]interface{} `json:"params"` @@ -48,6 +49,10 @@ type Credential struct { Invalid bool `json:"invalid" elastic_mapping:"invalid:{type:boolean}"` } +type AccessTokenPayload struct { + AccessToken ucfg.SecretString `json:"access_token,omitempty" config:"access_token" yaml:"access_token"` +} + func (cred *Credential) SetSecret(secret []byte) { cred.secret = secret } @@ -69,6 +74,8 @@ func (cred *Credential) Encode() error { switch cred.Type { case BasicAuth: return encodeBasicAuth(cred) + case AccessToken: + return encodeAccessToken(cred) default: return fmt.Errorf("unkonow credential type [%s]", cred.Type) } @@ -86,15 +93,33 @@ func (cred *Credential) DecodeBasicAuth() (*model.BasicAuth, error) { return nil, fmt.Errorf("unkonow credential type [%s]", cred.Type) } +func (cred *Credential) DecodeAccessToken() (*AccessTokenPayload, error) { + dv, err := cred.Decode() + if err != nil { + return nil, err + } + + if token, ok := dv.(AccessTokenPayload); ok { + return &token, nil + } + + return nil, fmt.Errorf("unkonow credential type [%s]", cred.Type) +} + func (cred *Credential) Decode() (interface{}, error) { switch cred.Type { case BasicAuth: return decodeBasicAuth(cred) + case AccessToken: + return decodeAccessToken(cred) default: return nil, fmt.Errorf("unkonow credential type [%s]", cred.Type) } } +type CredentialType string + const ( - BasicAuth string = "basic_auth" + BasicAuth CredentialType = "basic_auth" + AccessToken CredentialType = "access_token" ) diff --git a/core/credential/domain.go b/core/credential/domain.go index 2dbb05f4c..eac1b1cc5 100644 --- a/core/credential/domain.go +++ b/core/credential/domain.go @@ -85,6 +85,13 @@ func InitSecret(ks keystore2.Keystore, secret []byte) error { return nil } +func getCredentialSecret(cred *Credential) ([]byte, error) { + if cred != nil && cred.secret != nil { + return cred.secret, nil + } + return GetOrInitSecret() +} + func encodeBasicAuth(cred *Credential) error { var ( params map[string]interface{} @@ -100,7 +107,7 @@ func encodeBasicAuth(cred *Credential) error { if pwd == "" { return fmt.Errorf("credential parameters password can not be empty") } - secret, err := GetOrInitSecret() + secret, err := getCredentialSecret(cred) if err != nil { return err } @@ -117,6 +124,38 @@ func encodeBasicAuth(cred *Credential) error { return nil } +func encodeAccessToken(cred *Credential) error { + var ( + params map[string]interface{} + ok bool + token string + ) + if params, ok = cred.Payload[cred.Type].(map[string]interface{}); !ok { + return fmt.Errorf("wrong credential parameters for type [%s], expect a map", cred.Type) + } + if token, ok = params["access_token"].(string); !ok { + return fmt.Errorf("wrong credential parameters access_token for type [%s], expect a string", cred.Type) + } + if token == "" { + return fmt.Errorf("credential parameters access_token can not be empty") + } + secret, err := getCredentialSecret(cred) + if err != nil { + return err + } + encodeBytes, salt, err := util.AesGcmEncrypt([]byte(token), secret) + if err != nil { + return fmt.Errorf("encrypt access token error: %w", err) + } + cred.Encrypt.Type = "AES" + cred.Encrypt.Params = map[string]interface{}{ + "salt": string(salt), + } + params["access_token"] = string(encodeBytes) + cred.Payload[cred.Type] = params + return nil +} + func decodeBasicAuth(cred *Credential) (basicAuth model.BasicAuth, err error) { var ( params map[string]interface{} @@ -157,6 +196,42 @@ func decodeBasicAuth(cred *Credential) (basicAuth model.BasicAuth, err error) { return } +func decodeAccessToken(cred *Credential) (tokenPayload AccessTokenPayload, err error) { + var ( + params map[string]interface{} + ok bool + token string + salt string + ) + if params, ok = cred.Payload[cred.Type].(map[string]interface{}); !ok { + err = fmt.Errorf("wrong credential parameters for type [%s], expect a map", cred.Type) + return + } + if token, ok = params["access_token"].(string); !ok { + err = fmt.Errorf("wrong credential parameters access_token for type [%s], expect a string", cred.Type) + return + } + if token == "" { + err = fmt.Errorf("credential parameters access_token can not be empty") + return + } + if salt, ok = cred.Encrypt.Params["salt"].(string); !ok { + err = fmt.Errorf("credential encrypt parameters salt can not be empty") + return + } + secret, err := getCredentialSecret(cred) + if err != nil { + return tokenPayload, err + } + + plaintext, err := util.AesGcmDecrypt([]byte(token), secret, []byte(salt)) + if err != nil { + return tokenPayload, err + } + tokenPayload.AccessToken = ucfg.SecretString(plaintext) + return +} + type ChangeEvent func(credentials *Credential) var changeEvents []ChangeEvent diff --git a/core/model/const.go b/core/model/const.go new file mode 100644 index 000000000..425a867f1 --- /dev/null +++ b/core/model/const.go @@ -0,0 +1,8 @@ +/* Copyright © INFINI LTD. All rights reserved. + * Web: https://infinilabs.com + * Email: hello#infini.ltd */ + +package model + +const API_TOKEN = "X-API-TOKEN" +const CredentialIDSystemKey = "credential_id" diff --git a/core/model/instance.go b/core/model/instance.go index f9c60b44b..18d79fea8 100644 --- a/core/model/instance.go +++ b/core/model/instance.go @@ -56,6 +56,8 @@ type Instance struct { BasicAuth *BasicAuth `config:"basic_auth" json:"basic_auth,omitempty" elastic_mapping:"basic_auth:{type:object}"` + AccessToken string `json:"access_token,omitempty" elastic_mapping:"access_token:{type:keyword}"` + Labels map[string]string `json:"labels,omitempty" elastic_mapping:"labels:{type:object}"` Tags []string `json:"tags,omitempty"` @@ -69,8 +71,6 @@ type Instance struct { Network NetworkInfo `json:"network,omitempty" elastic_mapping:"network: { type: object }"` Services []ServiceInfo `json:"services,omitempty" elastic_mapping:"services: { type: object }"` Status string `json:"status,omitempty" elastic_mapping:"status: { type: keyword, copy_to:search_text }"` - - //SearchText string `json:"search_text,omitempty" elastic_mapping:"search_text:{type:text,index_prefixes:{},index_phrases:true, analyzer:suggest_text_search }"` } type ServiceInfo struct { @@ -137,7 +137,11 @@ func GetInstanceInfo() Instance { _, publicIP, _, _ := util.GetPublishNetworkDeviceInfo(global.Env().SystemConfig.NodeConfig.MajorIpPattern) - instance.Endpoint = global.Env().SystemConfig.APIConfig.GetEndpoint() + if !global.Env().SystemConfig.APIConfig.Enabled && global.Env().SystemConfig.WebAppConfig.Enabled { + instance.Endpoint = global.Env().SystemConfig.WebAppConfig.GetEndpoint() + } else { + instance.Endpoint = global.Env().SystemConfig.APIConfig.GetEndpoint() + } ips := util.GetLocalIPs() if len(ips) > 0 { diff --git a/docs/content.en/docs/release-notes/_index.md b/docs/content.en/docs/release-notes/_index.md index fefcf930f..fdeae5174 100644 --- a/docs/content.en/docs/release-notes/_index.md +++ b/docs/content.en/docs/release-notes/_index.md @@ -16,6 +16,10 @@ Information about release notes of INFINI Framework is provided here. ### 🚀 Features - feat: support team-based scope for sharing services #258 - feat: add semantic, hybrid, and nested query support #265 +### 🐛 Bug fix +- fix: use the web endpoint in instance info when the API listener is disabled, and keep managed config sync requests authenticated after registration +### ✈️ Improvements +- chore: add post-register hooks so managed clients can run follow-up steps like token exchange immediately after a successful register - feat: extract BuildFuzzinessQueryClauses as public API #266 - feat(keystore): support large stdin secrets (>1024 bytes) and multiline #271 - feat(cors): add X-SERVICE-ID to allowed CORS headers #275 diff --git a/modules/configs/client/client.go b/modules/configs/client/client.go index f863434b2..ca47df32a 100644 --- a/modules/configs/client/client.go +++ b/modules/configs/client/client.go @@ -53,12 +53,43 @@ import ( const bucketName = "instance_registered" const configRegisterEnvKey = "CONFIG_MANAGED_SUCCESS" +var postRegisterHooks []func() error +var postRegisterHooksLock sync.RWMutex + +func AddPostRegisterHook(hook func() error) { + if hook == nil { + return + } + postRegisterHooksLock.Lock() + defer postRegisterHooksLock.Unlock() + postRegisterHooks = append(postRegisterHooks, hook) +} + +func execPostRegisterHooks() error { + postRegisterHooksLock.RLock() + defer postRegisterHooksLock.RUnlock() + for _, hook := range postRegisterHooks { + if hook == nil { + continue + } + if err := hook(); err != nil { + return err + } + } + return nil +} + func ConnectToManager() error { if !global.Env().SystemConfig.Configs.Managed { return nil } + //register to config manager + if global.Env().SystemConfig.Configs.Servers == nil || len(global.Env().SystemConfig.Configs.Servers) == 0 { + return errors.Errorf("no config manager was found") + } + // k8s env setting always_register_after_restart and pod after restart the ip will change so need register again if !global.Env().SystemConfig.Configs.AlwaysRegisterAfterRestart { if exists, err := kv.ExistsKey(bucketName, []byte(global.Env().SystemConfig.NodeConfig.ID)); exists && err == nil { @@ -71,11 +102,6 @@ func ConnectToManager() error { log.Info("register new instance to config manager") - //register to config manager - if global.Env().SystemConfig.Configs.Servers == nil || len(global.Env().SystemConfig.Configs.Servers) == 0 { - return errors.Errorf("no config manager was found") - } - info := model.GetInstanceInfo() req := util.Request{Method: util.Verb_POST} @@ -83,35 +109,51 @@ func ConnectToManager() error { req.Path = common.REGISTER_API req.Body = util.MustToJSONBytes(info) - server, res, err := submitRequestToManager(&req) + server, res, err := DoManagerRequest(&req) if err == nil && server != "" { if res.StatusCode == 200 || util.ContainStr(string(res.Body), "exists") { + if err := execPostRegisterHooks(); err != nil { + return err + } log.Infof("success register to config manager: %v", string(server)) err := kv.AddValue(bucketName, []byte(global.Env().SystemConfig.NodeConfig.ID), []byte(util.GetLowPrecisionCurrentTime().String())) if err != nil { panic(err) } global.Register(configRegisterEnvKey, true) + return nil } + err = errors.Errorf("failed to register to config manager, status: %d, body: %s", res.StatusCode, string(res.Body)) } else { log.Error("failed to register to config manager,", err, ",", server) } return err } -func submitRequestToManager(req *util.Request) (string, *util.Result, error) { +func applyManagerRequestAuth(req *util.Request, username, password, token string) { + if username != "" { + req.SetBasicAuth(username, password) + } + if token != "" { + req.AddHeader(model.API_TOKEN, token) + } +} + +func DoManagerRequest(req *util.Request) (string, *util.Result, error) { var err error var res *util.Result cfg := global.Env().SystemConfig.Configs - if cfg.ManagerConfig.BasicAuth.Username != "" { - req.SetBasicAuth(cfg.ManagerConfig.BasicAuth.Username, cfg.ManagerConfig.BasicAuth.Password.Get()) + applyManagerRequestAuth(req, cfg.ManagerConfig.BasicAuth.Username, cfg.ManagerConfig.BasicAuth.Password.Get(), cfg.ManagerConfig.AccessToken.Get()) + httpClient, err := getManagerHTTPClient() + if err != nil { + return "", nil, err } for _, server := range cfg.Servers { req.Url, err = url.JoinPath(server, req.Path) if err != nil { continue } - res, err = util.ExecuteRequestWithCatchFlag(mTLSClient, req, true) + res, err = util.ExecuteRequestWithCatchFlag(httpClient, req, true) if err != nil { continue } @@ -120,21 +162,29 @@ func submitRequestToManager(req *util.Request) (string, *util.Result, error) { return "", nil, err } -var clientInitLock = sync.Once{} -var mTLSClient *http.Client +var managerHTTPClientOnce sync.Once +var managerHTTPClient *http.Client +var managerHTTPClientErr error -func ListenConfigChanges() error { +func getManagerHTTPClient() (*http.Client, error) { + managerHTTPClientOnce.Do(func() { + cfg := global.Env().GetHTTPClientConfig("configs", "") + if cfg == nil { + return + } + managerHTTPClient, managerHTTPClientErr = api.NewHTTPClient(cfg) + }) + return managerHTTPClient, managerHTTPClientErr +} + +var clientInitLock sync.Once +func ListenConfigChanges() error { clientInitLock.Do(func() { if global.Env().SystemConfig.Configs.Managed { - cfg := global.Env().GetHTTPClientConfig("configs", "") - if cfg != nil { - hClient, err := api.NewHTTPClient(cfg) - if err != nil { - panic(err) - } - mTLSClient = hClient + if _, err := getManagerHTTPClient(); err != nil { + panic(err) } //init config sync listening @@ -160,7 +210,7 @@ func ListenConfigChanges() error { log.Debug("config sync request: ", string(util.MustToJSONBytes(req))) } - _, res, err := submitRequestToManager(&request) + _, res, err := DoManagerRequest(&request) if err != nil { log.Error("failed to submit request to config manager,", err) return diff --git a/modules/configs/client/client_test.go b/modules/configs/client/client_test.go new file mode 100644 index 000000000..e613ded21 --- /dev/null +++ b/modules/configs/client/client_test.go @@ -0,0 +1,29 @@ +package client + +import ( + "infini.sh/framework/core/model" + "testing" + + "infini.sh/framework/core/util" + "infini.sh/framework/modules/configs/common" +) + +func TestApplyManagerRequestAuthAddsRegisterTokenHeader(t *testing.T) { + req := util.Request{Path: common.REGISTER_API} + + applyManagerRequestAuth(&req, "", "", "token-1") + + if got := req.AllHeaders()[model.API_TOKEN]; got != "token-1" { + t.Fatalf("expected register token header %q, got %q", "token-1", got) + } +} + +func TestApplyManagerRequestAuthAddsTokenHeaderForSync(t *testing.T) { + req := util.Request{Path: common.SYNC_API} + + applyManagerRequestAuth(&req, "", "", "token-1") + + if got := req.AllHeaders()[model.API_TOKEN]; got != "token-1" { + t.Fatalf("expected sync token header %q, got %q", "token-1", got) + } +} diff --git a/modules/security/access_token/authentication.go b/modules/security/access_token/authentication.go index 3d5a7f90b..5ae6b8348 100644 --- a/modules/security/access_token/authentication.go +++ b/modules/security/access_token/authentication.go @@ -175,7 +175,7 @@ func RequestAccessToken(w http.ResponseWriter, req *http.Request, ps httprouter. }{} err = api.DecodeJSON(req, &reqBody) if err != nil { - panic(errors.ErrorWithHTTPCode(err, 400, "invalid token")) + panic(errors.NewWithHTTPCode(400, "invalid token")) } if reqBody.Name == "" { reqBody.Name = GenerateApiTokenName("") @@ -187,7 +187,7 @@ func RequestAccessToken(w http.ResponseWriter, req *http.Request, ps httprouter. if len(reqBody.Permissions) > 0 { // requested permissions must be within the caller's own scope if !util.IsSuperset(security.ConvertPermissionKeysToHashSet(permissions), security.ConvertPermissionKeysToHashSet(reqBody.Permissions)) { - panic(errors.ErrorWithHTTPCode(err, 403, "invalid permissions")) + panic(errors.NewWithHTTPCode(403, "invalid permissions")) } permissions = reqBody.Permissions }