From 635aecfd2a06a53537c7784771cee0813021d014 Mon Sep 17 00:00:00 2001 From: Markus Vahlenkamp Date: Wed, 9 Aug 2023 22:14:54 +0200 Subject: [PATCH] Provision ssh keys for srlinux via both authorized_keys file and config (#1504) * add srl linuxadmin authorized keys via config * update * dedup sshkeys * store ssh pub keys in CLab * renamed func * use map for keys dedup, drop extra ssh type * extract keys once and use in authz file creation also do not fail when keys extraction fails * store SrlVersion in the node * renamed pointer receiver s->n for unification * store ssh keys in srl struct, retrieve srl version early and catenate keys * catenate without extra space after the last key * added a test for FileLines * added key filtering and stopped using pointers to interfaces * removed unused context in banner and added debug logs * blind eye for unsafe defer --------- Co-authored-by: Roman Dodin --- clab/authz_keys.go | 95 ++++++++++++++++++++++++------------- clab/clab.go | 6 +++ cmd/deploy.go | 5 ++ go.mod | 2 +- nodes/node.go | 2 + nodes/srl/banner.go | 27 +++-------- nodes/srl/srl.go | 44 +++++++++++++---- nodes/srl/sshkey.go | 43 +++++++++++++++++ nodes/srl/sshkey_test.go | 89 ++++++++++++++++++++++++++++++++++ nodes/srl/test_data/keys | 2 + nodes/srl/test_data/rsa_key | 1 + nodes/srl/version.go | 31 +++++++++++- utils/file.go | 26 ++++++++++ utils/file_test.go | 41 +++++++++++++++- utils/keys.go | 35 ++++++++++++++ utils/test_data/keys1.txt | 3 ++ 16 files changed, 387 insertions(+), 65 deletions(-) create mode 100644 nodes/srl/sshkey.go create mode 100644 nodes/srl/sshkey_test.go create mode 100644 nodes/srl/test_data/keys create mode 100644 nodes/srl/test_data/rsa_key create mode 100644 utils/keys.go create mode 100644 utils/test_data/keys1.txt diff --git a/clab/authz_keys.go b/clab/authz_keys.go index 790225084..f4727359f 100644 --- a/clab/authz_keys.go +++ b/clab/authz_keys.go @@ -6,6 +6,7 @@ package clab import ( "bytes" + "errors" "fmt" "net" "os" @@ -14,6 +15,7 @@ import ( log "github.com/sirupsen/logrus" "github.com/srl-labs/containerlab/utils" + "golang.org/x/crypto/ssh" "golang.org/x/crypto/ssh/agent" ) @@ -23,69 +25,91 @@ const ( authzKeysFPath = "~/.ssh/authorized_keys" ) -// CreateAuthzKeysFile creats the authorized_keys file in the lab directory -// if any files ~/.ssh/*.pub found. +// CreateAuthzKeysFile creates the authorized_keys file in the lab directory +// using the public ssh keys retrieved from agent and local files. func (c *CLab) CreateAuthzKeysFile() error { b := new(bytes.Buffer) + for _, k := range c.SSHPubKeys { + x := strings.TrimSpace(string(ssh.MarshalAuthorizedKey(k))) + addKeyToBuffer(b, x) + } + + clabAuthzKeysFPath := c.TopoPaths.AuthorizedKeysFilename() + if err := utils.CreateFile(clabAuthzKeysFPath, b.String()); err != nil { + return err + } + + // ensure authz_keys will have the permissions allowing it to be read by anyone + return os.Chmod(clabAuthzKeysFPath, 0644) // skipcq: GSC-G302 +} + +// RetrieveSSHPubKeysFromFiles retrieves public keys from the ~/.ssh/*.authorized_keys +// and ~/.ssh/*.pub files. +func RetrieveSSHPubKeysFromFiles() ([]ssh.PublicKey, error) { + var keys []ssh.PublicKey p := utils.ResolvePath(pubKeysGlob, "") all, err := filepath.Glob(p) if err != nil { - return fmt.Errorf("failed globbing the path %s", p) + return nil, fmt.Errorf("failed globbing the path %s", p) } f := utils.ResolvePath(authzKeysFPath, "") if utils.FileExists(f) { - log.Debugf("%s found, adding the public keys it contains", f) + log.Debugf("%s found, adding it to the list of files to get public keys from", f) all = append(all, f) } - // get keys registered with ssh-agent - keys, err := SSHAgentKeys() + keys, err = utils.LoadSSHPubKeysFromFiles(all) if err != nil { - log.Debug(err) + return nil, err } - log.Debugf("extracted %d keys from ssh-agent", len(keys)) - for _, k := range keys { - addKeyToBuffer(b, k) + return keys, nil +} + +// RetrieveSSHPubKeys retrieves the PubKeys from the different sources +// SSHAgent as well as all home dir based /.ssh/*.pub files. +func (c *CLab) RetrieveSSHPubKeys() ([]ssh.PublicKey, error) { + keys := make([]ssh.PublicKey, 0) + + var errs error + + // any errors encountered during the retrieval of the keys are not fatal + // we accumulate them and log. + fkeys, err := RetrieveSSHPubKeysFromFiles() + if err != nil { + errs = errors.Join(err) } - for _, fn := range all { - rb, err := os.ReadFile(fn) - if err != nil { - return fmt.Errorf("failed reading the file %s: %v", fn, err) - } + agentKeys, err := RetrieveSSHAgentKeys() + if err != nil { + errs = errors.Join(err) + } - addKeyToBuffer(b, string(rb)) + keysM := map[string]ssh.PublicKey{} + for _, k := range append(fkeys, agentKeys...) { + keysM[string(ssh.MarshalAuthorizedKey(k))] = k } - clabAuthzKeysFPath := c.TopoPaths.AuthorizedKeysFilename() - if err := utils.CreateFile(clabAuthzKeysFPath, b.String()); err != nil { - return err + for _, k := range keysM { + keys = append(keys, k) } - // ensure authz_keys will have the permissions allowing it to be read by anyone - return os.Chmod(clabAuthzKeysFPath, 0644) // skipcq: GSC-G302 + return keys, errs } // addKeyToBuffer adds a key to the buffer if the key is not already present. func addKeyToBuffer(b *bytes.Buffer, key string) { - // since they key might have a comment as a third field, we need to strip it - elems := strings.Fields(key) - if len(elems) < 2 { - return - } - - if !strings.Contains(b.String(), elems[1]) { + if !strings.Contains(b.String(), key) { b.WriteString(key + "\n") } } -// SSHAgentKeys retrieves public keys registered with the ssh-agent. -func SSHAgentKeys() ([]string, error) { +// RetrieveSSHAgentKeys retrieves public keys registered with the ssh-agent. +func RetrieveSSHAgentKeys() ([]ssh.PublicKey, error) { socket := os.Getenv("SSH_AUTH_SOCK") if len(socket) == 0 { return nil, fmt.Errorf("SSH_AUTH_SOCK not set, skipping pubkey fetching") @@ -101,9 +125,16 @@ func SSHAgentKeys() ([]string, error) { return nil, fmt.Errorf("error listing agent's pub keys %w", err) } - var pubKeys []string + log.Debugf("extracted %d keys from ssh-agent", len(keys)) + + var pubKeys []ssh.PublicKey + for _, key := range keys { - pubKeys = append(pubKeys, key.String()) + pkey, err := ssh.ParsePublicKey(key.Blob) + if err != nil { + return nil, err + } + pubKeys = append(pubKeys, pkey) } return pubKeys, nil diff --git a/clab/clab.go b/clab/clab.go index 0cfe5986f..434e665ce 100644 --- a/clab/clab.go +++ b/clab/clab.go @@ -23,6 +23,7 @@ import ( "github.com/srl-labs/containerlab/runtime/docker" "github.com/srl-labs/containerlab/runtime/ignite" "github.com/srl-labs/containerlab/types" + "golang.org/x/crypto/ssh" "golang.org/x/exp/slices" "golang.org/x/sync/semaphore" ) @@ -38,6 +39,10 @@ type CLab struct { // reg is a registry of node kinds Reg *nodes.NodeRegistry Cert *cert.Cert + // List of SSH public keys extracted from the ~/.ssh/authorized_keys file + // and ~/.ssh/*.pub files. + // The keys are used to enable key-based SSH access for the nodes. + SSHPubKeys []ssh.PublicKey timeout time.Duration } @@ -404,6 +409,7 @@ func (c *CLab) scheduleNodes(ctx context.Context, maxWorkers int, Cert: c.Cert, TopologyName: c.Config.Name, TopoPaths: c.TopoPaths, + SSHPubKeys: c.SSHPubKeys, }, ) if err != nil { diff --git a/cmd/deploy.go b/cmd/deploy.go index af6bcdb16..2aa9872c7 100644 --- a/cmd/deploy.go +++ b/cmd/deploy.go @@ -181,6 +181,11 @@ func deployFn(_ *cobra.Command, _ []string) error { return err } + c.SSHPubKeys, err = c.RetrieveSSHPubKeys() + if err != nil { + log.Warn(err) + } + if err := c.CreateAuthzKeysFile(); err != nil { return err } diff --git a/go.mod b/go.mod index 9adcc26a4..916169344 100644 --- a/go.mod +++ b/go.mod @@ -264,7 +264,7 @@ require ( go4.org/intern v0.0.0-20230205224052-192e9f60865c // indirect go4.org/unsafe/assume-no-moving-gc v0.0.0-20230204201903-c31fa085b70e // indirect gocloud.dev v0.29.0 // indirect - golang.org/x/mod v0.10.0 // indirect + golang.org/x/mod v0.10.0 golang.org/x/net v0.14.0 golang.org/x/oauth2 v0.9.0 // indirect golang.org/x/sync v0.3.0 diff --git a/nodes/node.go b/nodes/node.go index 446231097..e2d208d14 100644 --- a/nodes/node.go +++ b/nodes/node.go @@ -14,6 +14,7 @@ import ( "github.com/srl-labs/containerlab/clab/exec" "github.com/srl-labs/containerlab/runtime" "github.com/srl-labs/containerlab/types" + "golang.org/x/crypto/ssh" ) const ( @@ -50,6 +51,7 @@ type PreDeployParams struct { Cert *cert.Cert TopologyName string TopoPaths *types.TopoPaths + SSHPubKeys []ssh.PublicKey } // DeployParams contains parameters for the Deploy function. diff --git a/nodes/srl/banner.go b/nodes/srl/banner.go index b0449d5be..bb7da466d 100644 --- a/nodes/srl/banner.go +++ b/nodes/srl/banner.go @@ -1,11 +1,7 @@ package srl import ( - "context" "fmt" - - log "github.com/sirupsen/logrus" - "github.com/srl-labs/containerlab/clab/exec" ) const banner = `................................................................ @@ -26,28 +22,17 @@ const banner = `................................................................ ` // banner returns a banner string with a docs version filled in based on the version information queried from the node. -func (s *srl) banner(ctx context.Context) (string, error) { - cmd, _ := exec.NewExecCmdFromString(`sr_cli -d "info from state /system information version | grep version"`) - - execResult, err := s.RunExec(ctx, cmd) - if err != nil { - return "", err - } - - log.Debugf("node %s. stdout: %s, stderr: %s", s.Cfg.ShortName, execResult.GetStdOutString(), execResult.GetStdErrString()) - - v := s.parseVersionString(execResult.GetStdOutString()) - +func (n *srl) banner() (string, error) { // if minor is a single digit value, we need to add extra space to patch version // to have banner table aligned nicely - if len(v.minor) == 1 { - v.patch = v.patch + " " + if len(n.swVersion.minor) == 1 { + n.swVersion.patch = n.swVersion.patch + " " } b := fmt.Sprintf(banner, - v.major, v.minor, - v.major, v.minor, v.patch, - v.major, v.minor, v.patch) + n.swVersion.major, n.swVersion.minor, + n.swVersion.major, n.swVersion.minor, n.swVersion.patch, + n.swVersion.major, n.swVersion.minor, n.swVersion.patch) return b, nil } diff --git a/nodes/srl/srl.go b/nodes/srl/srl.go index d3f8c18c7..91703ec25 100644 --- a/nodes/srl/srl.go +++ b/nodes/srl/srl.go @@ -21,6 +21,8 @@ import ( "github.com/hairyhenderson/gomplate/v3/data" "github.com/pkg/errors" log "github.com/sirupsen/logrus" + "golang.org/x/crypto/ssh" + "golang.org/x/mod/semver" "github.com/srl-labs/containerlab/cert" "github.com/srl-labs/containerlab/clab/exec" @@ -65,6 +67,10 @@ set / interface ethernet-{{index $parts 0}}/{{index $parts 1}} breakout-mode num set / interface ethernet-{{index $parts 0}}/{{index $parts 1}}/{{index $parts 2}} admin-state enable {{- end }} {{ end -}} +{{- if .SSHPubKeys }} +set / system aaa authentication linuxadmin-user ssh-key [ {{ .SSHPubKeys }} ] +set / system aaa authentication admin-user ssh-key [ {{ .SSHPubKeys }} ] +{{- end }} set / system banner login-banner "{{ .Banner }}" commit save` ) @@ -137,6 +143,10 @@ type srl struct { // to generate certificates cert *cert.Cert topologyName string + // SSH public keys extracted from the clab host + sshPubKeys []ssh.PublicKey + // software version SR Linux node runs + swVersion *SrlVersion } func (s *srl) Init(cfg *types.NodeConfig, opts ...nodes.NodeOption) error { @@ -247,6 +257,9 @@ func (s *srl) PreDeploy(_ context.Context, params *nodes.PreDeployParams) error ) } + // store provided pubkeys + s.sshPubKeys = params.SSHPubKeys + // store the certificate-related parameters // for cert generation to happen in Post-Deploy phase with mgmt IPs as SANs s.cert = params.Cert @@ -285,6 +298,11 @@ func (s *srl) PostDeploy(ctx context.Context, params *nodes.PostDeployParams) er return err } + s.swVersion, err = s.RunningVersion(ctx) + if err != nil { + return err + } + // return if config file is found in the lab directory. // This can be either if the startup-config has been mounted by that path // or the config has been previously generated and saved @@ -505,8 +523,8 @@ func generateSRLTopologyFile(cfg *types.NodeConfig) error { } // addDefaultConfig adds srl default configuration such as tls certs, gnmi/json-rpc, login-banner. -func (s *srl) addDefaultConfig(ctx context.Context) error { - b, err := s.banner(ctx) +func (n *srl) addDefaultConfig(ctx context.Context) error { + b, err := n.banner() if err != nil { return err } @@ -514,10 +532,20 @@ func (s *srl) addDefaultConfig(ctx context.Context) error { // struct that holds data used in templating of the default config snippet tplData := struct { *types.NodeConfig - Banner string + Banner string + SSHPubKeys string }{ - s.Cfg, + n.Cfg, b, + "", + } + + n.filterSSHPubKeys() + + // in srlinux >= v23.7+ linuxadmin and admin user ssh keys can only be configured via the cli + // so we add the keys to the template data for rendering. + if semver.Compare(n.swVersion.String(), "v23.7") >= 0 || n.swVersion.major == "0" { + tplData.SSHPubKeys = catenateKeys(n.sshPubKeys) } // remove newlines from tls key/cert so that they nicely apply via the cli provisioning @@ -531,13 +559,13 @@ func (s *srl) addDefaultConfig(ctx context.Context) error { return err } - log.Debugf("Node %q additional config:\n%s", s.Cfg.ShortName, buf.String()) + log.Debugf("Node %q additional config:\n%s", n.Cfg.ShortName, buf.String()) execCmd := exec.NewExecCmdFromSlice([]string{ "bash", "-c", fmt.Sprintf("echo '%s' > /tmp/clab-config", buf.String()), }) - _, err = s.RunExec(ctx, execCmd) + _, err = n.RunExec(ctx, execCmd) if err != nil { return err } @@ -547,12 +575,12 @@ func (s *srl) addDefaultConfig(ctx context.Context) error { return err } - execResult, err := s.RunExec(ctx, cmd) + execResult, err := n.RunExec(ctx, cmd) if err != nil { return err } - log.Debugf("node %s. stdout: %s, stderr: %s", s.Cfg.ShortName, execResult.GetStdOutString(), execResult.GetStdErrString()) + log.Debugf("node %s. stdout: %s, stderr: %s", n.Cfg.ShortName, execResult.GetStdOutString(), execResult.GetStdErrString()) return nil } diff --git a/nodes/srl/sshkey.go b/nodes/srl/sshkey.go new file mode 100644 index 000000000..cfa2d682e --- /dev/null +++ b/nodes/srl/sshkey.go @@ -0,0 +1,43 @@ +package srl + +import ( + "fmt" + "strings" + + "golang.org/x/crypto/ssh" +) + +// catenateKeys catenates the ssh public keys +// and produces a string that can be used in the +// cli config command to set the ssh public keys +// for users. +func catenateKeys(in []ssh.PublicKey) string { + var keys string + + for i, k := range in { + // marshall the publickey in authorizedKeys format + // and trim spaces (cause there will be a trailing newline) + ks := strings.TrimSpace(string(ssh.MarshalAuthorizedKey(k))) + // catenate all ssh keys into a single quoted string accepted in CLI + keys += fmt.Sprintf("%q", ks) + // only add a space after the key if it is not the last one + if i < len(in)-1 { + keys += " " + } + } + + return keys +} + +// filterSSHPubKeys removes non-rsa keys from n.sshPubKeys until srl adds support for them. +func (n *srl) filterSSHPubKeys() { + filteredKeys := []ssh.PublicKey{} + + for _, k := range n.sshPubKeys { + if k.Type() == "ssh-rsa" { + filteredKeys = append(filteredKeys, k) + } + } + + n.sshPubKeys = filteredKeys +} diff --git a/nodes/srl/sshkey_test.go b/nodes/srl/sshkey_test.go new file mode 100644 index 000000000..fe78834c3 --- /dev/null +++ b/nodes/srl/sshkey_test.go @@ -0,0 +1,89 @@ +package srl + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/srl-labs/containerlab/utils" +) + +func Test_srl_catenateKeys(t *testing.T) { + type fields struct { + keyFiles []string + } + tests := []struct { + name string + fields fields + want string + }{ + { + name: "test1", + fields: fields{ + keyFiles: []string{"test_data/keys"}, + }, + want: "\"ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCs4Qv1yrBk6ygt+o7J4sUcYv+WfDjdAyABDoinOt3PgSmCcVqqAP2qS8UtTnMNuy93Orp6+/R/7/R3O5xdY6I4YViK3WVlKTAUVm7vdeTKp9uq1tNeWgo7+J3baSbQ3INp85ScTfFvRzRCFkr/W97Wh6pTa7ysgkcPvc2/tXG2z36Mx7/TFBk3Q1LY3ByKLtGrC5JnVpMTrqrsCwcLEVHHEZ4z5R4FZED/lpz+wTNFnR/l9HA6yDkKYensHynx+guqYpYD6y4yEGY/LcUnwBg0zIlUhmOsvdmxWBz12Lp7EBiNjSwhnPfe+o3efLGGnjWUAa4TgO8Sa8PQP0pK/ZNd\" \"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILKdXYzPIq8kHRJtDrh21wMVI76AnuPk7HDLeDteKN74\"", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + keys, err := utils.LoadSSHPubKeysFromFiles(tt.fields.keyFiles) + if err != nil { + t.Errorf("failed to load keys: %v", err) + } + + n := &srl{ + sshPubKeys: keys, + } + + got := catenateKeys(n.sshPubKeys) + + if d := cmp.Diff(got, tt.want); d != "" { + t.Errorf("srl.catenateKeys() = %s", d) + } + }) + } +} + +func Test_srl_filterSSHPubKeys(t *testing.T) { + type fields struct { + keyFiles []string + } + tests := []struct { + name string + fields fields + want string + }{ + { + name: "test1", + fields: fields{ + keyFiles: []string{"test_data/keys"}, + }, + want: "\"ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCs4Qv1yrBk6ygt+o7J4sUcYv+WfDjdAyABDoinOt3PgSmCcVqqAP2qS8UtTnMNuy93Orp6+/R/7/R3O5xdY6I4YViK3WVlKTAUVm7vdeTKp9uq1tNeWgo7+J3baSbQ3INp85ScTfFvRzRCFkr/W97Wh6pTa7ysgkcPvc2/tXG2z36Mx7/TFBk3Q1LY3ByKLtGrC5JnVpMTrqrsCwcLEVHHEZ4z5R4FZED/lpz+wTNFnR/l9HA6yDkKYensHynx+guqYpYD6y4yEGY/LcUnwBg0zIlUhmOsvdmxWBz12Lp7EBiNjSwhnPfe+o3efLGGnjWUAa4TgO8Sa8PQP0pK/ZNd\" \"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILKdXYzPIq8kHRJtDrh21wMVI76AnuPk7HDLeDteKN74\"", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + allKeys, err := utils.LoadSSHPubKeysFromFiles(tt.fields.keyFiles) + if err != nil { + t.Errorf("failed to load keys: %v", err) + } + + rsaKeys, err := utils.LoadSSHPubKeysFromFiles([]string{"test_data/rsa_key"}) + if err != nil { + t.Errorf("failed to load keys: %v", err) + } + + n := &srl{ + sshPubKeys: allKeys, + } + + n.filterSSHPubKeys() + + got := catenateKeys(n.sshPubKeys) + want := catenateKeys(rsaKeys) + if d := cmp.Diff(got, want); d != "" { + t.Errorf("srl.filterSSHPubKeys() = %s", d) + } + }) + } +} diff --git a/nodes/srl/test_data/keys b/nodes/srl/test_data/keys new file mode 100644 index 000000000..c709ba159 --- /dev/null +++ b/nodes/srl/test_data/keys @@ -0,0 +1,2 @@ +ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCs4Qv1yrBk6ygt+o7J4sUcYv+WfDjdAyABDoinOt3PgSmCcVqqAP2qS8UtTnMNuy93Orp6+/R/7/R3O5xdY6I4YViK3WVlKTAUVm7vdeTKp9uq1tNeWgo7+J3baSbQ3INp85ScTfFvRzRCFkr/W97Wh6pTa7ysgkcPvc2/tXG2z36Mx7/TFBk3Q1LY3ByKLtGrC5JnVpMTrqrsCwcLEVHHEZ4z5R4FZED/lpz+wTNFnR/l9HA6yDkKYensHynx+guqYpYD6y4yEGY/LcUnwBg0zIlUhmOsvdmxWBz12Lp7EBiNjSwhnPfe+o3efLGGnjWUAa4TgO8Sa8PQP0pK/ZNd +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILKdXYzPIq8kHRJtDrh21wMVI76AnuPk7HDLeDteKN74 \ No newline at end of file diff --git a/nodes/srl/test_data/rsa_key b/nodes/srl/test_data/rsa_key new file mode 100644 index 000000000..16aeb7f9c --- /dev/null +++ b/nodes/srl/test_data/rsa_key @@ -0,0 +1 @@ +ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCs4Qv1yrBk6ygt+o7J4sUcYv+WfDjdAyABDoinOt3PgSmCcVqqAP2qS8UtTnMNuy93Orp6+/R/7/R3O5xdY6I4YViK3WVlKTAUVm7vdeTKp9uq1tNeWgo7+J3baSbQ3INp85ScTfFvRzRCFkr/W97Wh6pTa7ysgkcPvc2/tXG2z36Mx7/TFBk3Q1LY3ByKLtGrC5JnVpMTrqrsCwcLEVHHEZ4z5R4FZED/lpz+wTNFnR/l9HA6yDkKYensHynx+guqYpYD6y4yEGY/LcUnwBg0zIlUhmOsvdmxWBz12Lp7EBiNjSwhnPfe+o3efLGGnjWUAa4TgO8Sa8PQP0pK/ZNd \ No newline at end of file diff --git a/nodes/srl/version.go b/nodes/srl/version.go index 1cd877e37..3684c0ff3 100644 --- a/nodes/srl/version.go +++ b/nodes/srl/version.go @@ -1,6 +1,12 @@ package srl -import "regexp" +import ( + "context" + "regexp" + + log "github.com/sirupsen/logrus" + "github.com/srl-labs/containerlab/clab/exec" +) // SrlVersion represents an sr linux version as a set of fields. type SrlVersion struct { @@ -11,7 +17,23 @@ type SrlVersion struct { commit string } -func (*srl) parseVersionString(s string) *SrlVersion { +// RunningVersion gets the software version of the running node +// by executing the "info from state /system information version | grep version" command +// and parsing the output. +func (n *srl) RunningVersion(ctx context.Context) (*SrlVersion, error) { + cmd, _ := exec.NewExecCmdFromString(`sr_cli -d "info from state /system information version | grep version"`) + + execResult, err := n.RunExec(ctx, cmd) + if err != nil { + return nil, err + } + + log.Debugf("node %s. stdout: %s, stderr: %s", n.Cfg.ShortName, execResult.GetStdOutString(), execResult.GetStdErrString()) + + return n.parseVersionString(execResult.GetStdOutString()), nil +} + +func (n *srl) parseVersionString(s string) *SrlVersion { re, _ := regexp.Compile(`v(\d{1,3})\.(\d{1,2})\.(\d{1,3})\-(\d{1,4})\-(\S+)`) v := re.FindStringSubmatch(s) @@ -23,3 +45,8 @@ func (*srl) parseVersionString(s string) *SrlVersion { return &SrlVersion{v[1], v[2], v[3], v[4], v[5]} } + +// String returns a string representation of the version in a semver fashion (with leading v). +func (v *SrlVersion) String() string { + return "v" + v.major + "." + v.minor + "." + v.patch + "-" + v.build + "-" + v.commit +} diff --git a/utils/file.go b/utils/file.go index 05aff8935..eefa4ad00 100644 --- a/utils/file.go +++ b/utils/file.go @@ -5,6 +5,7 @@ package utils import ( + "bufio" "errors" "fmt" "io" @@ -275,3 +276,28 @@ func FilenameForURL(rawUrl string) string { } return filepath.Base(u.Path) } + +// FileLines opens a file by the `path` and returns a slice of strings for each line +// excluding lines that start with `commentStr` or are empty. +func FileLines(path, commentStr string) ([]string, error) { + f, err := os.Open(path) + if err != nil { + return nil, fmt.Errorf("failed to open file %v: %w", path, err) + } + defer f.Close() // skipcq: GO-S2307 + + var lines []string + + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := scanner.Text() + // Skip lines that start with comment char + if strings.HasPrefix(line, commentStr) || line == "" { + continue + } + + lines = append(lines, line) + } + + return lines, nil +} diff --git a/utils/file_test.go b/utils/file_test.go index 751b421f6..1dbd4b582 100644 --- a/utils/file_test.go +++ b/utils/file_test.go @@ -4,7 +4,11 @@ package utils -import "testing" +import ( + "testing" + + "github.com/google/go-cmp/cmp" +) func TestFilenameForURL(t *testing.T) { type args struct { @@ -45,3 +49,38 @@ func TestFilenameForURL(t *testing.T) { }) } } + +func TestFileLines(t *testing.T) { + type args struct { + path string + commentStr string + } + tests := []struct { + name string + args args + want []string + wantErr bool + }{ + { + name: "regular file", + args: args{ + path: "test_data/keys1.txt", + commentStr: "#", + }, + want: []string{"valid line", "another valid line"}, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := FileLines(tt.args.path, tt.args.commentStr) + if (err != nil) != tt.wantErr { + t.Errorf("FileLines() error = %v, wantErr %v", err, tt.wantErr) + return + } + if d := cmp.Diff(got, tt.want); d != "" { + t.Errorf("FileLines() diff = %s", d) + } + }) + } +} diff --git a/utils/keys.go b/utils/keys.go new file mode 100644 index 000000000..a311bc13d --- /dev/null +++ b/utils/keys.go @@ -0,0 +1,35 @@ +package utils + +import ( + log "github.com/sirupsen/logrus" + "golang.org/x/crypto/ssh" +) + +// LoadSSHPubKeysFromFiles parses openssh keys from the files referenced by the paths +// and returns a slice of ssh.PublicKey pointers. +// The files may contain multiple keys each on a separate line. +func LoadSSHPubKeysFromFiles(paths []string) ([]ssh.PublicKey, error) { + var keys []ssh.PublicKey + + for _, p := range paths { + lines, err := FileLines(p, "#") + if err != nil { + return nil, err + } + + for _, l := range lines { + pubKey, _, _, _, err := ssh.ParseAuthorizedKey([]byte(l)) + + log.Debugf("Loaded public key %s", l) + + if err != nil { + return nil, err + } + + keys = append(keys, pubKey) + } + + } + + return keys, nil +} diff --git a/utils/test_data/keys1.txt b/utils/test_data/keys1.txt new file mode 100644 index 000000000..c11e547dd --- /dev/null +++ b/utils/test_data/keys1.txt @@ -0,0 +1,3 @@ +valid line +# comment +another valid line \ No newline at end of file