Merge pull request #1422 from ywk253100/170221_secretkey

Encrypt the passwords in configuration file
This commit is contained in:
Daniel Jiang 2017-02-23 10:25:39 +08:00 committed by GitHub
commit ef34b96256
20 changed files with 412 additions and 98 deletions

View File

@ -25,9 +25,9 @@ env:
HARBOR_ADMIN_PASSWD: Harbor12345
UI_SECRET: tempString
MAX_JOB_WORKERS: 3
SECRET_KEY: 1234567890123456
AUTH_MODE: db_auth
SELF_REGISTRATION: on
KEY_PATH: /data/secretkey
before_install:
- sudo ./tests/hostcfg.sh

View File

@ -48,7 +48,8 @@ services:
- ../common/config/adminserver/env
restart: always
volumes:
- /data/config/:/etc/harbor/
- /data/config/:/etc/adminserver/
- /data/secretkey:/etc/adminserver/key
depends_on:
- log
logging:
@ -66,6 +67,7 @@ services:
volumes:
- ../common/config/ui/app.conf:/etc/ui/app.conf
- ../common/config/ui/private_key.pem:/etc/ui/private_key.pem
- /data/secretkey:/etc/ui/key
depends_on:
- log
- adminserver
@ -85,6 +87,7 @@ services:
volumes:
- /data/job_logs:/var/log/jobs
- ../common/config/jobservice/app.conf:/etc/jobservice/app.conf
- /data/secretkey:/etc/jobservice/key
depends_on:
- ui
- adminserver

View File

@ -48,7 +48,8 @@ services:
- ./common/config/adminserver/env
restart: always
volumes:
- /data/config/:/etc/harbor/
- /data/config/:/etc/adminserver/
- /data/secretkey:/etc/adminserver/key
depends_on:
- log
logging:
@ -66,6 +67,7 @@ services:
- ./common/config/ui/app.conf:/etc/ui/app.conf
- ./common/config/ui/private_key.pem:/etc/ui/private_key.pem
- /data:/harbor_storage
- /data/secretkey:/etc/ui/key
depends_on:
- log
- adminserver
@ -84,6 +86,7 @@ services:
volumes:
- /data/job_logs:/var/log/jobs
- ./common/config/jobservice/app.conf:/etc/jobservice/app.conf
- /data/secretkey:/etc/jobservice/key
depends_on:
- ui
- adminserver

View File

@ -26,34 +26,50 @@ import (
"testing"
"github.com/vmware/harbor/src/adminserver/systemcfg"
"github.com/vmware/harbor/src/common/config"
comcfg "github.com/vmware/harbor/src/common/config"
"github.com/vmware/harbor/src/common/utils/test"
)
func TestConfigAPI(t *testing.T) {
path := "/tmp/config.json"
secret := "secret"
configPath := "/tmp/config.json"
secretKeyPath := "/tmp/secretkey"
_, err := test.GenerateKey(secretKeyPath)
if err != nil {
t.Errorf("failed to generate secret key: %v", err)
return
}
defer os.Remove(secretKeyPath)
secret := "secret"
envs := map[string]string{
"JSON_STORE_PATH": path,
"UI_SECRET": secret,
"MYSQL_PORT": "3306",
"TOKEN_EXPIRATION": "30",
"CFG_EXPIRATION": "5",
"MAX_JOB_WORKERS": "3",
"LDAP_SCOPE": "3",
"LDAP_TIMEOUT": "30",
"EMAIL_PORT": "25",
"JSON_CFG_STORE_PATH": configPath,
"KEY_PATH": secretKeyPath,
"UI_SECRET": secret,
"MYSQL_PORT": "3306",
"TOKEN_EXPIRATION": "30",
"CFG_EXPIRATION": "5",
"MAX_JOB_WORKERS": "3",
"LDAP_SCOPE": "3",
"LDAP_TIMEOUT": "30",
"EMAIL_PORT": "25",
"MYSQL_PWD": "",
"LDAP_SEARCH_PWD": "",
"EMAIL_PWD": "",
"HARBOR_ADMIN_PASSWORD": "",
}
for k, v := range envs {
if err := os.Setenv(k, v); err != nil {
t.Fatalf("failed to set env %s: %v", k, err)
t.Errorf("failed to set env %s: %v", k, err)
return
}
}
defer os.Remove(path)
defer os.Remove(configPath)
if err := systemcfg.Init(); err != nil {
t.Errorf("failed to initialize systemconfigurations: %v", err)
t.Errorf("failed to initialize system configurations: %v", err)
return
}
@ -88,7 +104,7 @@ func TestConfigAPI(t *testing.T) {
return
}
scope := int(m[config.LDAPScope].(float64))
scope := int(m[comcfg.LDAPScope].(float64))
if scope != 3 {
t.Errorf("unexpected ldap scope: %d != %d", scope, 3)
return
@ -96,7 +112,7 @@ func TestConfigAPI(t *testing.T) {
// modify configurations
c := map[string]interface{}{
config.AUTHMode: config.LDAPAuth,
comcfg.AUTHMode: comcfg.LDAPAuth,
}
b, err := json.Marshal(c)
@ -146,9 +162,9 @@ func TestConfigAPI(t *testing.T) {
return
}
mode := m[config.AUTHMode].(string)
if mode != config.LDAPAuth {
t.Errorf("unexpected ldap scope: %s != %s", mode, config.LDAPAuth)
mode := m[comcfg.AUTHMode].(string)
if mode != comcfg.LDAPAuth {
t.Errorf("unexpected ldap scope: %s != %s", mode, comcfg.LDAPAuth)
return
}
}

View File

@ -19,7 +19,7 @@ import (
"net/http"
"os"
cfg "github.com/vmware/harbor/src/adminserver/systemcfg"
syscfg "github.com/vmware/harbor/src/adminserver/systemcfg"
"github.com/vmware/harbor/src/common/utils/log"
)
@ -41,7 +41,7 @@ func (s *Server) Serve() error {
func main() {
log.Info("initializing system configurations...")
if err := cfg.Init(); err != nil {
if err := syscfg.Init(); err != nil {
log.Fatalf("failed to initialize the system: %v", err)
}
log.Info("system initialization completed")

View File

@ -23,27 +23,39 @@ import (
"github.com/vmware/harbor/src/adminserver/systemcfg/store"
"github.com/vmware/harbor/src/adminserver/systemcfg/store/json"
comcfg "github.com/vmware/harbor/src/common/config"
"github.com/vmware/harbor/src/common/utils"
"github.com/vmware/harbor/src/common/utils/log"
)
var cfgStore store.Driver
const (
defaultCfgStoreDriver string = "json"
defaultJSONCfgStorePath string = "/etc/adminserver/config.json"
defaultKeyPath string = "/etc/adminserver/key"
)
var (
// attrs need to be encrypted or decrypted
attrs = []string{
comcfg.EmailPassword,
comcfg.LDAPSearchPwd,
comcfg.MySQLPassword,
comcfg.AdminInitialPassword,
}
cfgStore store.Driver
keyProvider comcfg.KeyProvider
)
// Init system configurations. Read from config store first, if null read from env
func Init() (err error) {
s := getCfgStore()
switch s {
case "json":
path := os.Getenv("JSON_STORE_PATH")
cfgStore, err = json.NewCfgStore(path)
if err != nil {
return
}
default:
return fmt.Errorf("unsupported configuration store driver %s", s)
//init configuation store
if err = initCfgStore(); err != nil {
return err
}
log.Infof("configuration store driver: %s", cfgStore.Name())
cfg, err := cfgStore.Read()
//init key provider
initKeyProvider()
cfg, err := GetSystemCfg()
if err != nil {
return err
}
@ -61,19 +73,44 @@ func Init() (err error) {
}
//sync configurations into cfg store
if err = cfgStore.Write(cfg); err != nil {
if err = UpdateSystemCfg(cfg); err != nil {
return err
}
return nil
}
func getCfgStore() string {
t := os.Getenv("CFG_STORE_TYPE")
func initCfgStore() (err error) {
t := os.Getenv("CFG_STORE_DRIVER")
if len(t) == 0 {
t = "json"
t = defaultCfgStoreDriver
}
return t
log.Infof("configuration store driver: %s", t)
switch t {
case "json":
path := os.Getenv("JSON_CFG_STORE_PATH")
if len(path) == 0 {
path = defaultJSONCfgStorePath
}
log.Infof("json configuration store path: %s", path)
cfgStore, err = json.NewCfgStore(path)
default:
err = fmt.Errorf("unsupported configuration store driver %s", t)
}
return err
}
func initKeyProvider() {
path := os.Getenv("KEY_PATH")
if len(path) == 0 {
path = defaultKeyPath
}
log.Infof("key path: %s", path)
keyProvider = comcfg.NewFileKeyProvider(path)
}
//read the following attrs from env every time boots up
@ -102,7 +139,6 @@ func readFromEnv(cfg map[string]interface{}) error {
cfg[comcfg.JobLogDir] = os.Getenv("LOG_DIR")
//TODO remove
cfg[comcfg.UseCompressedJS] = os.Getenv("USE_COMPRESSED_JS") == "on"
cfg[comcfg.SecretKey] = os.Getenv("SECRET_KEY")
cfgExpi, err := strconv.Atoi(os.Getenv("CFG_EXPIRATION"))
if err != nil {
return err
@ -162,10 +198,74 @@ func initFromEnv() (map[string]interface{}, error) {
// GetSystemCfg returns the system configurations
func GetSystemCfg() (map[string]interface{}, error) {
return cfgStore.Read()
m, err := cfgStore.Read()
if err != nil {
return nil, err
}
key, err := keyProvider.Get(nil)
if err != nil {
return nil, fmt.Errorf("failed to get key: %v", err)
}
if err = decrypt(m, attrs, key); err != nil {
return nil, err
}
return m, nil
}
// UpdateSystemCfg updates the system configurations
func UpdateSystemCfg(cfg map[string]interface{}) error {
key, err := keyProvider.Get(nil)
if err != nil {
return fmt.Errorf("failed to get key: %v", err)
}
if err := encrypt(cfg, attrs, key); err != nil {
return err
}
return cfgStore.Write(cfg)
}
func encrypt(m map[string]interface{}, keys []string, secretKey string) error {
for _, key := range keys {
v, ok := m[key]
if !ok {
continue
}
if len(v.(string)) == 0 {
continue
}
cipherText, err := utils.ReversibleEncrypt(v.(string), secretKey)
if err != nil {
return err
}
m[key] = cipherText
}
return nil
}
func decrypt(m map[string]interface{}, keys []string, secretKey string) error {
for _, key := range keys {
v, ok := m[key]
if !ok {
continue
}
if len(v.(string)) == 0 {
continue
}
text, err := utils.ReversibleDecrypt(v.(string), secretKey)
if err != nil {
return err
}
m[key] = text
}
return nil
}

View File

@ -20,33 +20,52 @@ import (
"testing"
comcfg "github.com/vmware/harbor/src/common/config"
"github.com/vmware/harbor/src/common/utils/test"
)
// test functions under adminserver/systemcfg
// test functions in adminserver/systemcfg/systemcfg.go
func TestSystemcfg(t *testing.T) {
key := "JSON_STORE_PATH"
path := "/tmp/config.json"
if _, err := os.Stat(path); err == nil {
if err := os.Remove(path); err != nil {
t.Fatalf("failed to remove %s: %v", path, err)
configPath := "/tmp/config.json"
if _, err := os.Stat(configPath); err == nil {
if err := os.Remove(configPath); err != nil {
t.Errorf("failed to remove %s: %v", configPath, err)
return
}
} else if !os.IsNotExist(err) {
t.Fatalf("failed to check the existence of %s: %v", path, err)
t.Errorf("failed to check the existence of %s: %v", configPath, err)
return
}
if err := os.Setenv(key, path); err != nil {
t.Fatalf("failed to set env %s: %v", key, err)
if err := os.Setenv("JSON_CFG_STORE_PATH", configPath); err != nil {
t.Errorf("failed to set env: %v", err)
return
}
keyPath := "/tmp/secretkey"
if _, err := test.GenerateKey(keyPath); err != nil {
t.Errorf("failed to generate key: %v", err)
return
}
defer os.Remove(keyPath)
if err := os.Setenv("KEY_PATH", keyPath); err != nil {
t.Errorf("failed to set env: %v", err)
return
}
m := map[string]string{
"AUTH_MODE": comcfg.DBAuth,
"LDAP_SCOPE": "1",
"LDAP_TIMEOUT": "30",
"MYSQL_PORT": "3306",
"MAX_JOB_WORKERS": "3",
"TOKEN_EXPIRATION": "30",
"CFG_EXPIRATION": "5",
"EMAIL_PORT": "25",
"AUTH_MODE": comcfg.DBAuth,
"LDAP_SCOPE": "1",
"LDAP_TIMEOUT": "30",
"MYSQL_PORT": "3306",
"MAX_JOB_WORKERS": "3",
"TOKEN_EXPIRATION": "30",
"CFG_EXPIRATION": "5",
"EMAIL_PORT": "25",
"MYSQL_PWD": "",
"LDAP_SEARCH_PWD": "",
"EMAIL_PWD": "",
"HARBOR_ADMIN_PASSWORD": "",
}
for k, v := range m {
@ -59,11 +78,7 @@ func TestSystemcfg(t *testing.T) {
t.Errorf("failed to initialize system configurations: %v", err)
return
}
defer func() {
if err := os.Remove(path); err != nil {
t.Fatalf("failed to remove %s: %v", path, err)
}
}()
defer os.Remove(configPath)
// run Init again to make sure it works well when the configuration file
// already exists

View File

@ -74,7 +74,6 @@ const (
CfgExpiration = "cfg_expiration"
JobLogDir = "job_log_dir"
UseCompressedJS = "use_compressed_js"
SecretKey = "secret_key"
AdminInitialPassword = "admin_initial_password"
)

View File

@ -0,0 +1,47 @@
/*
Copyright (c) 2016 VMware, Inc. All Rights Reserved.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package config
import (
"io/ioutil"
)
// KeyProvider provides the key used to encrypt and decrypt attrs
type KeyProvider interface {
// Get returns the key
// params can be used to pass parameters in different implements
Get(params map[string]interface{}) (string, error)
}
// FileKeyProvider reads key from file
type FileKeyProvider struct {
path string
}
// NewFileKeyProvider returns an instance of FileKeyProvider
// path: where the key should be read from
func NewFileKeyProvider(path string) KeyProvider {
return &FileKeyProvider{
path: path,
}
}
// Get returns the key read from file
func (f *FileKeyProvider) Get(params map[string]interface{}) (string, error) {
b, err := ioutil.ReadFile(f.path)
if err != nil {
return "", err
}
return string(b), nil
}

View File

@ -0,0 +1,43 @@
/*
Copyright (c) 2016 VMware, Inc. All Rights Reserved.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package config
import (
"io/ioutil"
"os"
"testing"
)
func TestGetOfFileKeyProvider(t *testing.T) {
path := "/tmp/key"
key := "key_content"
if err := ioutil.WriteFile(path, []byte(key), 0777); err != nil {
t.Errorf("failed to write to file %s: %v", path, err)
return
}
defer os.Remove(path)
provider := NewFileKeyProvider(path)
k, err := provider.Get(nil)
if err != nil {
t.Errorf("failed to get key from the file provider: %v", err)
return
}
if k != key {
t.Errorf("unexpected key: %s != %s", k, key)
return
}
}

View File

@ -24,7 +24,7 @@ import (
)
var adminServerDefaultConfig = map[string]interface{}{
config.ExtEndpoint: "host01.com",
config.ExtEndpoint: "host01.com",
config.AUTHMode: config.DBAuth,
config.DatabaseType: "mysql",
config.MySQLHost: "127.0.0.1",
@ -58,7 +58,6 @@ var adminServerDefaultConfig = map[string]interface{}{
config.CfgExpiration: 5,
config.JobLogDir: "/var/log/jobs",
config.UseCompressedJS: true,
config.SecretKey: "secret",
config.AdminInitialPassword: "password",
}

View File

@ -0,0 +1,41 @@
/*
Copyright (c) 2016 VMware, Inc. All Rights Reserved.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package test
import (
"crypto/aes"
"crypto/rand"
"fmt"
"io/ioutil"
)
// GenerateKey generates aes key
func GenerateKey(path string) (string, error) {
data := make([]byte, aes.BlockSize)
n, err := rand.Read(data)
if err != nil {
return "", fmt.Errorf("failed to generate random bytes: %v", err)
}
if n != aes.BlockSize {
return "", fmt.Errorf("the length of random bytes %d != %d", n, aes.BlockSize)
}
if err = ioutil.WriteFile(path, data, 0777); err != nil {
return "", fmt.Errorf("failed write secret key to file %s: %v", path, err)
}
return string(data), nil
}

View File

@ -20,12 +20,23 @@ import (
comcfg "github.com/vmware/harbor/src/common/config"
"github.com/vmware/harbor/src/common/models"
"github.com/vmware/harbor/src/common/utils/log"
)
var mg *comcfg.Manager
const (
defaultKeyPath string = "/etc/jobservice/key"
)
var (
mg *comcfg.Manager
keyProvider comcfg.KeyProvider
)
// Init configurations
func Init() error {
//init key provider
initKeyProvider()
adminServerURL := os.Getenv("ADMIN_SERVER_URL")
if len(adminServerURL) == 0 {
adminServerURL = "http://adminserver"
@ -43,6 +54,16 @@ func Init() error {
return nil
}
func initKeyProvider() {
path := os.Getenv("KEY_PATH")
if len(path) == 0 {
path = defaultKeyPath
}
log.Infof("key path: %s", path)
keyProvider = comcfg.NewFileKeyProvider(path)
}
// VerifyRemoteCert returns bool value.
func VerifyRemoteCert() (bool, error) {
cfg, err := mg.Get()
@ -108,15 +129,11 @@ func LogDir() (string, error) {
// SecretKey will return the secret key for encryption/decryption password in target.
func SecretKey() (string, error) {
cfg, err := mg.Get()
if err != nil {
return "", err
}
return cfg[comcfg.SecretKey].(string), nil
return keyProvider.Get(nil)
}
// UISecret returns the value of UI secret cookie, used for communication between UI and JobService
// TODO
// UISecret returns a secret used for communication of UI, JobService
// and Adminserver
func UISecret() string {
return os.Getenv("UI_SECRET")
}

View File

@ -30,13 +30,22 @@ func TestConfig(t *testing.T) {
}
defer server.Close()
url := os.Getenv("ADMIN_SERVER_URL")
defer os.Setenv("ADMIN_SERVER_URL", url)
if err := os.Setenv("ADMIN_SERVER_URL", server.URL); err != nil {
t.Fatalf("failed to set env %s: %v", "ADMIN_SERVER_URL", err)
}
secretKeyPath := "/tmp/secretkey"
_, err = test.GenerateKey(secretKeyPath)
if err != nil {
t.Errorf("failed to generate secret key: %v", err)
return
}
defer os.Remove(secretKeyPath)
if err := os.Setenv("KEY_PATH", secretKeyPath); err != nil {
t.Fatalf("failed to set env %s: %v", "KEY_PATH", err)
}
if err := Init(); err != nil {
t.Fatalf("failed to initialize configurations: %v", err)
}

View File

@ -461,7 +461,6 @@ func (m *ManifestPusher) enter() (string, error) {
func newRepositoryClient(endpoint string, insecure bool, credential auth.Credential,
tokenServiceEndpoint, repository, scopeType, scopeName string,
scopeActions ...string) (*registry.Repository, error) {
authorizer := auth.NewStandardTokenAuthorizer(credential, insecure,
tokenServiceEndpoint, scopeType, scopeName, scopeActions...)

View File

@ -215,8 +215,6 @@ func validateCfg(c map[string]string) (bool, error) {
}
}
log.Infof("===========%v", c)
if ldapURL, ok := c[comcfg.LDAPURL]; ok && len(ldapURL) == 0 {
return isSysErr, fmt.Errorf("%s is empty", comcfg.LDAPURL)
}
@ -325,8 +323,7 @@ func convertForGet(cfg map[string]interface{}) (map[string]*value, error) {
comcfg.AdminInitialPassword,
comcfg.EmailPassword,
comcfg.LDAPSearchPwd,
comcfg.MySQLPassword,
comcfg.SecretKey}
comcfg.MySQLPassword}
for _, del := range dels {
if _, ok := cfg[del]; ok {
delete(cfg, del)

View File

@ -24,10 +24,18 @@ import (
"github.com/vmware/harbor/src/common/utils/log"
)
var mg *comcfg.Manager
const defaultKeyPath string = "/etc/ui/key"
var (
mg *comcfg.Manager
keyProvider comcfg.KeyProvider
)
// Init configurations
func Init() error {
//init key provider
initKeyProvider()
adminServerURL := os.Getenv("ADMIN_SERVER_URL")
if len(adminServerURL) == 0 {
adminServerURL = "http://adminserver"
@ -46,6 +54,16 @@ func Init() error {
return nil
}
func initKeyProvider() {
path := os.Getenv("KEY_PATH")
if len(path) == 0 {
path = defaultKeyPath
}
log.Infof("key path: %s", path)
keyProvider = comcfg.NewFileKeyProvider(path)
}
// Load configurations
func Load() error {
_, err := mg.Load()
@ -125,11 +143,7 @@ func ExtEndpoint() (string, error) {
// SecretKey returns the secret key to encrypt the password of target
func SecretKey() (string, error) {
cfg, err := mg.Get()
if err != nil {
return "", err
}
return cfg[comcfg.SecretKey].(string), nil
return keyProvider.Get(nil)
}
// SelfRegistration returns the enablement of self registration
@ -228,8 +242,8 @@ func Database() (*models.Database, error) {
return database, nil
}
// UISecret returns the value of UI secret cookie, used for communication between UI and JobService
// TODO
// UISecret returns a secret used for communication of UI, JobService
// and Adminserver
func UISecret() string {
return os.Getenv("UI_SECRET")
}

View File

@ -29,13 +29,22 @@ func TestConfig(t *testing.T) {
}
defer server.Close()
url := os.Getenv("ADMIN_SERVER_URL")
defer os.Setenv("ADMIN_SERVER_URL", url)
if err := os.Setenv("ADMIN_SERVER_URL", server.URL); err != nil {
t.Fatalf("failed to set env %s: %v", "ADMIN_SERVER_URL", err)
}
secretKeyPath := "/tmp/secretkey"
_, err = test.GenerateKey(secretKeyPath)
if err != nil {
t.Errorf("failed to generate secret key: %v", err)
return
}
defer os.Remove(secretKeyPath)
if err := os.Setenv("KEY_PATH", secretKeyPath); err != nil {
t.Fatalf("failed to set env %s: %v", "KEY_PATH", err)
}
if err := Init(); err != nil {
t.Fatalf("failed to initialize configurations: %v", err)
}

View File

@ -29,7 +29,8 @@ services:
- ./common/config/adminserver/env
restart: always
volumes:
- /data/config/:/etc/harbor/
- /data/config/:/etc/adminserver/
- /data/secretkey:/etc/adminserver/key
ports:
- 8888:80
ldap:

View File

@ -11,3 +11,5 @@ cp make/common/config/ui/app.conf conf/.
sed -i -r "s/MYSQL_HOST=mysql/MYSQL_HOST=127.0.0.1/" make/common/config/adminserver/env
sed -i -r "s|REGISTRY_URL=http://registry:5000|REGISTRY_URL=http://127.0.0.1:5000|" make/common/config/adminserver/env
sed -i -r "s/UI_SECRET=.*/UI_SECRET=$UI_SECRET/" make/common/config/adminserver/env
chmod 777 /data/