diff --git a/clab/sshconfig.go b/clab/sshconfig.go index 7b5379874..2536d1c0f 100644 --- a/clab/sshconfig.go +++ b/clab/sshconfig.go @@ -20,8 +20,9 @@ type SSHConfigTmpl struct { // SSHConfigNodeTmpl represents values for a single node // in the sshconfig template. type SSHConfigNodeTmpl struct { - Name string - Username string + Name string + Username string + SSHConfig *types.SSHConfig } // tmplSshConfig is the SSH config template. @@ -32,8 +33,11 @@ Host {{ .Name }} {{- if ne .Username ""}} User {{ .Username }} {{- end }} - StrictHostKeyChecking=no + StrictHostKeyChecking=no UserKnownHostsFile=/dev/null + {{- if ne .SSHConfig.PubkeyAuthentication "" }} + PubkeyAuthentication={{ .SSHConfig.PubkeyAuthentication.String }} + {{- end }} {{ end }}` // RemoveSSHConfig removes the lab specific ssh config file @@ -65,8 +69,9 @@ func (c *CLab) AddSSHConfig() error { // the kind registered Username NodeRegistryEntry := c.Reg.Kind(n.Config().Kind) nodeData := SSHConfigNodeTmpl{ - Name: n.Config().LongName, - Username: NodeRegistryEntry.Credentials().GetUsername(), + Name: n.Config().LongName, + Username: NodeRegistryEntry.Credentials().GetUsername(), + SSHConfig: n.GetSSHConfig(), } tmpl.Nodes = append(tmpl.Nodes, nodeData) } diff --git a/mocks/mocknodes/node.go b/mocks/mocknodes/node.go index b9b95663a..713df80fb 100644 --- a/mocks/mocknodes/node.go +++ b/mocks/mocknodes/node.go @@ -277,6 +277,20 @@ func (mr *MockNodeMockRecorder) GetRuntime() *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetRuntime", reflect.TypeOf((*MockNode)(nil).GetRuntime)) } +// GetSSHConfig mocks base method. +func (m *MockNode) GetSSHConfig() *types.SSHConfig { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetSSHConfig") + ret0, _ := ret[0].(*types.SSHConfig) + return ret0 +} + +// GetSSHConfig indicates an expected call of GetSSHConfig. +func (mr *MockNodeMockRecorder) GetSSHConfig() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetSSHConfig", reflect.TypeOf((*MockNode)(nil).GetSSHConfig)) +} + // GetShortName mocks base method. func (m *MockNode) GetShortName() string { m.ctrl.T.Helper() diff --git a/nodes/default_node.go b/nodes/default_node.go index 272e6a0c1..372895657 100644 --- a/nodes/default_node.go +++ b/nodes/default_node.go @@ -35,6 +35,8 @@ type DefaultNode struct { Mgmt *types.MgmtNet Runtime runtime.ContainerRuntime HostRequirements *types.HostRequirements + // SSHConfig is the SSH client configuration that a clab node requires. + SSHConfig *types.SSHConfig // Indicates that the node should not start without no license file defined LicensePolicy types.LicensePolicy // OverwriteNode stores the interface used to overwrite methods defined @@ -57,6 +59,7 @@ func NewDefaultNode(n NodeOverwrites) *DefaultNode { HostRequirements: types.NewHostRequirements(), OverwriteNode: n, LicensePolicy: types.LicensePolicyNone, + SSHConfig: types.NewSSHConfig(), } return dn @@ -509,3 +512,7 @@ func (d *DefaultNode) SetState(s state.NodeState) { defer d.statemutex.Unlock() d.state = s } + +func (d *DefaultNode) GetSSHConfig() *types.SSHConfig { + return d.SSHConfig +} diff --git a/nodes/linux/linux.go b/nodes/linux/linux.go index 6ce254dd0..043699102 100644 --- a/nodes/linux/linux.go +++ b/nodes/linux/linux.go @@ -34,7 +34,6 @@ type linux struct { func (n *linux) Init(cfg *types.NodeConfig, opts ...nodes.NodeOption) error { // Init DefaultNode n.DefaultNode = *nodes.NewDefaultNode(n) - n.Cfg = cfg for _, o := range opts { o(n) diff --git a/nodes/node.go b/nodes/node.go index c9d0a7f62..d6fc74e83 100644 --- a/nodes/node.go +++ b/nodes/node.go @@ -107,6 +107,7 @@ type Node interface { ExecFunction(func(ns.NetNS) error) error GetState() state.NodeState SetState(state.NodeState) + GetSSHConfig() *types.SSHConfig } type NodeOption func(Node) diff --git a/nodes/vr_sros/sshKey.go b/nodes/vr_sros/sshKey.go new file mode 100644 index 000000000..f82f253ec --- /dev/null +++ b/nodes/vr_sros/sshKey.go @@ -0,0 +1,86 @@ +package vr_sros + +import ( + "bytes" + "context" + _ "embed" + "strings" + "text/template" + + "github.com/hairyhenderson/gomplate/v3" + "github.com/hairyhenderson/gomplate/v3/data" + log "github.com/sirupsen/logrus" + "golang.org/x/crypto/ssh" +) + +// importing Default Config template at compile time +// +//go:embed ssh_keys.go.tpl +var SROSSSHKeysTemplate string + +// mapSSHPubKeys goes over s.sshPubKeys and puts the supported keys to the corresponding +// slices associated with the supported SSH key algorithms. +// supportedSSHKeyAlgos key is a SSH key algorithm and the value is a pointer to the slice +// that is used to store the keys of the corresponding algorithm family. +// Two slices are used to store RSA and ECDSA keys separately. +// The slices are modified in place by reference, so no return values are needed. +func (s *vrSROS) mapSSHPubKeys(supportedSSHKeyAlgos map[string]*[]string) { + for _, k := range s.sshPubKeys { + sshKeys, ok := supportedSSHKeyAlgos[k.Type()] + if !ok { + log.Debugf("unsupported SSH Key Algo %q, skipping key", k.Type()) + continue + } + + // extract the fields + // + keyFields := strings.Fields(string(ssh.MarshalAuthorizedKey(k))) + + *sshKeys = append(*sshKeys, keyFields[1]) + } +} + +// SROSTemplateData holds ssh keys for template generation. +type SROSTemplateData struct { + SSHPubKeysRSA []string + SSHPubKeysECDSA []string +} + +// configureSSHPublicKeys cofigures public keys extracted from clab host +// on SR OS node using SSH. +func (s *vrSROS) configureSSHPublicKeys( + ctx context.Context, addr, platformName, + username, password string, pubKeys []ssh.PublicKey) error { + tplData := SROSTemplateData{} + + // a map of supported SSH key algorithms and the template slices + // the keys should be added to. + // In mapSSHPubKeys we map supported SSH key algorithms to the template slices. + supportedSSHKeyAlgos := map[string]*[]string{ + ssh.KeyAlgoRSA: &tplData.SSHPubKeysRSA, + ssh.KeyAlgoECDSA521: &tplData.SSHPubKeysECDSA, + ssh.KeyAlgoECDSA384: &tplData.SSHPubKeysECDSA, + ssh.KeyAlgoECDSA256: &tplData.SSHPubKeysECDSA, + } + + s.mapSSHPubKeys(supportedSSHKeyAlgos) + + t, err := template.New("SSHKeys").Funcs( + gomplate.CreateFuncs(context.Background(), new(data.Data))).Parse(SROSSSHKeysTemplate) + if err != nil { + return err + } + + buf := new(bytes.Buffer) + err = t.Execute(buf, tplData) + if err != nil { + return err + } + + err = s.applyPartialConfig(ctx, s.Cfg.MgmtIPv4Address, scrapliPlatformName, + defaultCredentials.GetUsername(), defaultCredentials.GetPassword(), + buf, + ) + + return err +} diff --git a/nodes/vr_sros/ssh_keys.go.tpl b/nodes/vr_sros/ssh_keys.go.tpl new file mode 100644 index 000000000..5cc63d806 --- /dev/null +++ b/nodes/vr_sros/ssh_keys.go.tpl @@ -0,0 +1,12 @@ +{{/* this is a template for sros public key config for ssh admin user access */}} + +{{/* to enable long list of keys from agent where the configured key may not be in the default first three keys */}} +/configure system security user-params attempts count 64 + +{{ range $index, $key := .SSHPubKeysRSA }} +/configure system security user-params local-user user "admin" public-keys rsa rsa-key {{ add $index 1 }} key-value {{ $key }} +{{ end }} + +{{ range $index, $key := .SSHPubKeysECDSA }} +/configure system security user-params local-user user "admin" public-keys ecdsa ecdsa-key {{ add $index 1 }} key-value {{ $key }} +{{ end }} \ No newline at end of file diff --git a/nodes/vr_sros/vr-sros.go b/nodes/vr_sros/vr-sros.go index e254098db..fb632f930 100644 --- a/nodes/vr_sros/vr-sros.go +++ b/nodes/vr_sros/vr-sros.go @@ -7,6 +7,8 @@ package vr_sros import ( "context" "fmt" + "io" + "os" "path" "path/filepath" "regexp" @@ -25,6 +27,7 @@ import ( "github.com/srl-labs/containerlab/nodes" "github.com/srl-labs/containerlab/types" "github.com/srl-labs/containerlab/utils" + "golang.org/x/crypto/ssh" ) var ( @@ -49,6 +52,8 @@ func Register(r *nodes.NodeRegistry) { type vrSROS struct { nodes.DefaultNode + // SSH public keys extracted from the clab host + sshPubKeys []ssh.PublicKey } func (s *vrSROS) Init(cfg *types.NodeConfig, opts ...nodes.NodeOption) error { @@ -57,6 +62,9 @@ func (s *vrSROS) Init(cfg *types.NodeConfig, opts ...nodes.NodeOption) error { // set virtualization requirement s.HostRequirements.VirtRequired = true s.LicensePolicy = types.LicensePolicyWarn + // SR OS requires unbound pubkey authentication mode until this is + // gets fixed in later SR OS relase. + s.SSHConfig.PubkeyAuthentication = types.PubkeyAuthValueUnbound s.Cfg = cfg for _, o := range opts { @@ -95,19 +103,28 @@ func (s *vrSROS) PreDeploy(_ context.Context, params *nodes.PreDeployParams) err if err != nil { return nil } + + // store public keys extracted from clab host + s.sshPubKeys = params.SSHPubKeys + return createVrSROSFiles(s) } func (s *vrSROS) PostDeploy(ctx context.Context, _ *nodes.PostDeployParams) error { if isPartialConfigFile(s.Cfg.StartupConfig) { - log.Infof("Waiting for %s to boot and apply config from %s", s.Cfg.LongName, s.Cfg.StartupConfig) + log.Infof("%s: applying config from %s", s.Cfg.LongName, s.Cfg.StartupConfig) ctx, cancel := context.WithTimeout(ctx, 5*time.Minute) defer cancel() - err := s.applyPartialConfig(ctx, s.Cfg.MgmtIPv4Address, scrapliPlatformName, + r, err := os.Open(s.Cfg.StartupConfig) + if err != nil { + return err + } + + err = s.applyPartialConfig(ctx, s.Cfg.MgmtIPv4Address, scrapliPlatformName, defaultCredentials.GetUsername(), defaultCredentials.GetPassword(), - s.Cfg.StartupConfig, + r, ) if err != nil { return err @@ -116,6 +133,16 @@ func (s *vrSROS) PostDeploy(ctx context.Context, _ *nodes.PostDeployParams) erro log.Infof("%s: configuration applied", s.Cfg.LongName) } + if len(s.sshPubKeys) > 0 { + err := s.configureSSHPublicKeys(ctx, s.Cfg.MgmtIPv4Address, scrapliPlatformName, + defaultCredentials.GetUsername(), defaultCredentials.GetPassword(), + s.sshPubKeys, + ) + if err != nil { + return err + } + } + return nil } @@ -188,11 +215,11 @@ func (s *vrSROS) isHealthy(ctx context.Context) bool { } // applyPartialConfig applies partial configuration to the SR OS. -func (s *vrSROS) applyPartialConfig(ctx context.Context, addr, platformName, username, password string, configFile string) error { +func (s *vrSROS) applyPartialConfig(ctx context.Context, addr, platformName, username, password string, config io.Reader) error { var err error var d *network.Driver - configContent, err := utils.ReadFileContent(configFile) + configContent, err := io.ReadAll(config) if err != nil { return err } @@ -202,6 +229,7 @@ func (s *vrSROS) applyPartialConfig(ctx context.Context, addr, platformName, use return nil } + log.Infof("Waiting for %[1]s to be ready. This may take a while. Monitor boot log with `sudo docker logs -f %[1]s`", s.Cfg.LongName) for loop := true; loop; { if !s.isHealthy(ctx) { time.Sleep(5 * time.Second) // cool-off period @@ -249,8 +277,10 @@ func (s *vrSROS) applyPartialConfig(ctx context.Context, addr, platformName, use } } } + // converting byte slice to newline delimited string slice + cfgs := strings.Split(string(configContent), "\n") - mr, err := d.SendConfigsFromFile(configFile) + mr, err := d.SendConfigs(cfgs) if err != nil || mr.Failed != nil { return fmt.Errorf("failed to apply config; error: %+v %+v", err, mr.Failed) } diff --git a/types/ssh_config.go b/types/ssh_config.go new file mode 100644 index 000000000..d497a65fa --- /dev/null +++ b/types/ssh_config.go @@ -0,0 +1,23 @@ +package types + +type PubkeyAuthValue string + +const ( + PubkeyAuthValueYes PubkeyAuthValue = "yes" + PubkeyAuthValueNo PubkeyAuthValue = "no" + PubkeyAuthValueHostBound PubkeyAuthValue = "host-bound" + PubkeyAuthValueUnbound PubkeyAuthValue = "unbound" +) + +func (p PubkeyAuthValue) String() string { + return string(p) +} + +// SSHConfig is the SSH client configuration that a clab node requires. +type SSHConfig struct { + PubkeyAuthentication PubkeyAuthValue +} + +func NewSSHConfig() *SSHConfig { + return &SSHConfig{} +}