From 024f0053e55d3d7d5272c96f2fed8f60ee03ad60 Mon Sep 17 00:00:00 2001 From: Markus Vahlenkamp Date: Thu, 17 Aug 2023 11:19:49 +0200 Subject: [PATCH] Support external CA-Cert (#1307) * allow for external CA to be loaded via env Vars * adding check for key and cert file existence * Certificate Enhancements adding keylength for CA adding setting->certificateauthority->... config knobs to the topology file * node based key length * please deepsource * rename KeyLength to KeySize * please deepsource * parsing validityDuration from config to time.Duration * move cert authority settings under topology, add tests and add keysize to node * recursive resolution of the nodes certificate struct * removed unused struct * rename explicit to external * fix tests * fix * fix nil pointer * added json schema * update * validity-duration for node certificates * added docs * added comments for merge function * use BoolPointer util function * remove different pointer bools * added comments to settings structs * remove WithExternalCA option * added path resolve and env vars override * bring back settings to global level and fix test paths * fix internal CA tests * use old return syntax * docs for tools sign key size * move CA init to certificateAuthoritySetup as it seems more appropriate * bring back StoreNodeCert func * removed unused receiver * use cert.Write when storing the CA * revert the change of storing ca with storeNodeCert --------- Co-authored-by: Roman Dodin --- cert/ca.go | 16 ++- cert/certificate.go | 20 ++-- cert/csr_input.go | 2 + cert/local_dir_cert_storage.go | 12 ++- clab/clab.go | 4 - clab/config.go | 1 + cmd/deploy.go | 105 +++++++++++++++----- cmd/tools_cert.go | 5 + docs/cmd/tools/cert/sign.md | 4 + docs/manual/cert.md | 56 ++++++++++- docs/manual/nodes.md | 9 ++ docs/manual/topo-def-file.md | 10 +- nodes/default_node.go | 4 +- nodes/srl/srl.go | 8 +- schemas/clab.schema.json | 83 ++++++++++++++++ tests/01-smoke/09-external-ca.clab.yml | 18 ++++ tests/01-smoke/09-external-ca.robot | 80 +++++++++++++++ tests/01-smoke/10-ca-parameter.robot | 129 +++++++++++++++++++++++++ tests/01-smoke/10-internal-ca.clab.yml | 34 +++++++ types/settings.go | 22 +++++ types/topo_paths.go | 68 ++++++++++--- types/topology.go | 24 +++-- types/topology_test.go | 31 +++--- types/types.go | 32 +++++- utils/pointer.go | 6 ++ 25 files changed, 684 insertions(+), 99 deletions(-) create mode 100644 tests/01-smoke/09-external-ca.clab.yml create mode 100644 tests/01-smoke/09-external-ca.robot create mode 100644 tests/01-smoke/10-ca-parameter.robot create mode 100644 tests/01-smoke/10-internal-ca.clab.yml create mode 100644 types/settings.go create mode 100644 utils/pointer.go diff --git a/cert/ca.go b/cert/ca.go index 1ed5ee08a..81d90181f 100644 --- a/cert/ca.go +++ b/cert/ca.go @@ -69,7 +69,7 @@ func (ca *CA) GenerateCACert(input *CACSRInput) (*Certificate, error) { } // generate key - caPrivKey, err := rsa.GenerateKey(rand.Reader, 2048) + caPrivKey, err := rsa.GenerateKey(rand.Reader, input.KeySize) if err != nil { return nil, err } @@ -108,6 +108,16 @@ func (ca *CA) GenerateAndSignNodeCert(input *NodeCSRInput) (*Certificate, error) // parse hosts from input to retrieve dns and ip SANs dns, ip := parseHostsInput(input.Hosts) + keysize := 2048 + if input.KeySize > 0 { + keysize = input.KeySize + } + + expiry := time.Until(time.Now().AddDate(1, 0, 0)) // 1 year as default + if input.Expiry > 0 { + expiry = input.Expiry + } + certTemplate := &x509.Certificate{ RawSubject: []byte{}, SerialNumber: big.NewInt(1658), @@ -121,13 +131,13 @@ func (ca *CA) GenerateAndSignNodeCert(input *NodeCSRInput) (*Certificate, error) DNSNames: dns, IPAddresses: ip, NotBefore: time.Now(), - NotAfter: time.Now().AddDate(1, 0, 0), // HARDCODED 1 year + NotAfter: time.Now().Add(expiry), SubjectKeyId: []byte{1, 2, 3, 4, 6}, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth, x509.ExtKeyUsageServerAuth}, KeyUsage: x509.KeyUsageDigitalSignature, } - newPrivKey, err := rsa.GenerateKey(rand.Reader, 2048) + newPrivKey, err := rsa.GenerateKey(rand.Reader, keysize) if err != nil { return nil, err } diff --git a/cert/certificate.go b/cert/certificate.go index 4398f5704..895d500a3 100644 --- a/cert/certificate.go +++ b/cert/certificate.go @@ -40,13 +40,17 @@ func NewCertificateFromFile(certFilePath, keyFilePath, csrFilePath string) (*Cer } // CSR - _, err = os.Stat(csrFilePath) - if err != nil { - log.Debugf("failed loading csr %s, continuing anyways", csrFilePath) - } else { - cert.Csr, err = utils.ReadFileContent(csrFilePath) + // The CSR might not be there, which is not an issue, just skip it + if csrFilePath != "" { + + _, err = os.Stat(csrFilePath) if err != nil { - return nil, err + log.Debugf("failed loading csr %s, continuing anyways", csrFilePath) + } else { + cert.Csr, err = utils.ReadFileContent(csrFilePath) + if err != nil { + return nil, err + } } } @@ -86,5 +90,7 @@ type CaPaths interface { NodeCertKeyAbsFilename(identifier string) string NodeCertCSRAbsFilename(identifier string) string NodeTLSDir(string) string - CaDir() string + CaCertAbsFilename() string + CaKeyAbsFilename() string + CaCSRAbsFilename() string } diff --git a/cert/csr_input.go b/cert/csr_input.go index b610b8939..c5058c4a8 100644 --- a/cert/csr_input.go +++ b/cert/csr_input.go @@ -10,6 +10,7 @@ type CACSRInput struct { Organization string OrganizationUnit string Expiry time.Duration + KeySize int } // NodeCSRInput struct. @@ -21,4 +22,5 @@ type NodeCSRInput struct { Organization string OrganizationUnit string Expiry time.Duration + KeySize int } diff --git a/cert/local_dir_cert_storage.go b/cert/local_dir_cert_storage.go index bbee50167..177c11535 100644 --- a/cert/local_dir_cert_storage.go +++ b/cert/local_dir_cert_storage.go @@ -1,6 +1,8 @@ package cert import ( + "path/filepath" + "github.com/srl-labs/containerlab/utils" ) @@ -18,7 +20,7 @@ func NewLocalDirCertStorage(paths CaPaths) *LocalDirCertStorage { // LoadCaCert loads the CA certificate from disk. func (c *LocalDirCertStorage) LoadCaCert() (*Certificate, error) { - return c.LoadNodeCert(c.paths.CaDir()) + return NewCertificateFromFile(c.paths.CaCertAbsFilename(), c.paths.CaKeyAbsFilename(), "") } // LoadNodeCert loads the node certificate from disk. @@ -30,9 +32,13 @@ func (c *LocalDirCertStorage) LoadNodeCert(nodeName string) (*Certificate, error return NewCertificateFromFile(certFilename, keyFilename, csrFilename) } -// StoreCaCert stores the given CA certificate in a file in the baseFolder. +// StoreCaCert stores the given CA certificate, its key and CSR on disk. func (c *LocalDirCertStorage) StoreCaCert(cert *Certificate) error { - return c.StoreNodeCert(c.paths.CaDir(), cert) + // CA cert/key/csr can only be stored in the labdir/.tls/ca dir, + // so we need to create it if it does not exist. + utils.CreateDirectory(filepath.Dir(c.paths.CaCertAbsFilename()), 0777) + + return cert.Write(c.paths.CaCertAbsFilename(), c.paths.CaKeyAbsFilename(), c.paths.CaCSRAbsFilename()) } // StoreNodeCert stores the given certificate in a file in the baseFolder. diff --git a/clab/clab.go b/clab/clab.go index adce71af0..31d4b19ad 100644 --- a/clab/clab.go +++ b/clab/clab.go @@ -199,10 +199,6 @@ func NewContainerLab(opts ...ClabOption) (*CLab, error) { var err error if c.TopoPaths.TopologyFileIsSet() { err = c.parseTopology() - - // init the Cert storage and CA - c.Cert.CertStorage = cert.NewLocalDirCertStorage(c.TopoPaths) - c.Cert.CA = cert.NewCA() } return c, err } diff --git a/clab/config.go b/clab/config.go index 1a3690eae..7c9f07da3 100644 --- a/clab/config.go +++ b/clab/config.go @@ -44,6 +44,7 @@ type Config struct { Name string `json:"name,omitempty"` Prefix *string `json:"prefix,omitempty"` Mgmt *types.MgmtNet `json:"mgmt,omitempty"` + Settings *types.Settings `json:"settings,omitempty"` Topology *types.Topology `json:"topology,omitempty"` // the debug flag value as passed via cli // may be used by other packages to enable debug logging diff --git a/cmd/deploy.go b/cmd/deploy.go index c9dd1a1db..02d889fa1 100644 --- a/cmd/deploy.go +++ b/cmd/deploy.go @@ -93,22 +93,7 @@ func deployFn(_ *cobra.Command, _ []string) error { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - // handle CTRL-C signal - sig := make(chan os.Signal, 1) - signal.Notify(sig, os.Interrupt, syscall.SIGTERM) - go func() { - <-sig - log.Errorf("Caught CTRL-C. Stopping deployment and cleaning up!") - cancel() - - // when interrupted, destroy the interrupted lab deployment with cleanup - cleanup = true - if err := destroyFn(destroyCmd, []string{}); err != nil { - log.Errorf("Failed to destroy lab: %v", err) - } - - os.Exit(1) // skipcq: RVV-A0003 - }() + setupCTRLCHandler(cancel) opts := []clab.ClabOption{ clab.WithTimeout(timeout), @@ -123,6 +108,7 @@ func deployFn(_ *cobra.Command, _ []string) error { ), clab.WithDebug(debug), } + c, err := clab.NewContainerLab(opts...) if err != nil { return err @@ -183,14 +169,7 @@ func deployFn(_ *cobra.Command, _ []string) error { return err } - // define the attributes used to generate the CA Cert - caCertInput := &cert.CACSRInput{ - CommonName: c.Config.Name + " lab CA", - Expiry: time.Until(time.Now().AddDate(1, 0, 0)), // should expire in a year from now - Organization: "containerlab", - } - - if err := c.LoadOrGenerateCA(caCertInput); err != nil { + if err := certificateAuthoritySetup(c); err != nil { return err } @@ -323,6 +302,84 @@ func deployFn(_ *cobra.Command, _ []string) error { return printContainerInspect(containers, deployFormat) } +// certificateAuthoritySetup sets up the certificate authority parameters. +func certificateAuthoritySetup(c *clab.CLab) error { + // init the Cert storage and CA + c.Cert.CertStorage = cert.NewLocalDirCertStorage(c.TopoPaths) + c.Cert.CA = cert.NewCA() + + s := c.Config.Settings + + // Set defaults for the CA parameters + keySize := 2048 + validityDuration := time.Until(time.Now().AddDate(1, 0, 0)) // 1 year as default + + // check that Settings.CertificateAuthority exists. + if s != nil && s.CertificateAuthority != nil { + // if ValidityDuration is set use the value + if s.CertificateAuthority.ValidityDuration != 0 { + validityDuration = s.CertificateAuthority.ValidityDuration + } + + // if KeyLength is set use the value + if s.CertificateAuthority.KeySize != 0 { + keySize = s.CertificateAuthority.KeySize + } + + // if external CA cert and and key are set, propagate to topopaths + extCACert := s.CertificateAuthority.Cert + extCAKey := s.CertificateAuthority.Key + + // override external ca and key from env vars + if v := os.Getenv("CLAB_CA_KEY_FILE"); v != "" { + extCAKey = v + } + + if v := os.Getenv("CLAB_CA_CERT_FILE"); v != "" { + extCACert = v + } + + if extCACert != "" && extCAKey != "" { + err := c.TopoPaths.SetExternalCaFiles(extCACert, extCAKey) + if err != nil { + return err + } + } + } + + // define the attributes used to generate the CA Cert + caCertInput := &cert.CACSRInput{ + CommonName: c.Config.Name + " lab CA", + Expiry: validityDuration, + Organization: "containerlab", + KeySize: keySize, + } + + return c.LoadOrGenerateCA(caCertInput) +} + +// setupCTRLCHandler sets-up the handler for CTRL-C +// The deployment will be stopped and a destroy action is +// performed when interrupt signal is received. +func setupCTRLCHandler(cancel context.CancelFunc) { + // handle CTRL-C signal + sig := make(chan os.Signal, 1) + signal.Notify(sig, os.Interrupt, syscall.SIGTERM) + go func() { + <-sig + log.Errorf("Caught CTRL-C. Stopping deployment and cleaning up!") + cancel() + + // when interrupted, destroy the interrupted lab deployment with cleanup + cleanup = true + if err := destroyFn(destroyCmd, []string{}); err != nil { + log.Errorf("Failed to destroy lab: %v", err) + } + + os.Exit(1) // skipcq: RVV-A0003 + }() +} + func setFlags(conf *clab.Config) { if name != "" { conf.Name = name diff --git a/cmd/tools_cert.go b/cmd/tools_cert.go index e71ee6bb3..fc0e8f90c 100644 --- a/cmd/tools_cert.go +++ b/cmd/tools_cert.go @@ -30,6 +30,7 @@ var ( certHosts []string caCertPath string caKeyPath string + keySize int ) func init() { @@ -60,6 +61,7 @@ func init() { signCertCmd.Flags().StringVarP(&path, "path", "p", "", "path to write certificate and key to. Default is current working directory") signCertCmd.Flags().StringVarP(&certNamePrefix, "name", "n", "cert", "certificate/key filename prefix") + signCertCmd.Flags().IntVarP(&keySize, "key-size", "", 2048, "private key size") } var certCmd = &cobra.Command{ @@ -111,6 +113,7 @@ func createCA(_ *cobra.Command, _ []string) error { Organization: organization, OrganizationUnit: organizationUnit, Expiry: expDuration, + KeySize: keySize, } caCert, err := ca.GenerateCACert(csrInput) @@ -149,6 +152,7 @@ func signCert(_ *cobra.Command, _ []string) error { log.Debugf("CA cert path: %q", caCertPath) if caCertPath != "" { + // TODO: we might also honor the External CA env vars here caCert, err = cert.NewCertificateFromFile(caCertPath, caKeyPath, "") if err != nil { return err @@ -178,6 +182,7 @@ func signCert(_ *cobra.Command, _ []string) error { Organization: organization, OrganizationUnit: organizationUnit, Expiry: expDuration, + KeySize: keySize, }) if err != nil { return err diff --git a/docs/cmd/tools/cert/sign.md b/docs/cmd/tools/cert/sign.md index dccf44495..aae1d6017 100644 --- a/docs/cmd/tools/cert/sign.md +++ b/docs/cmd/tools/cert/sign.md @@ -50,6 +50,10 @@ Certificate Organization (O) field is set with `--organization | -o` flag. Defau Certificate Organization Unit (OU) field is set with `--ou` flag. Defaults to `Containerlab Tools`. +### Key size + +To set the key size, use the `--key-size` flag. Defaults to `2048`. + ## Examples ```bash diff --git a/docs/manual/cert.md b/docs/manual/cert.md index f47dfbc16..b4ed62d2a 100644 --- a/docs/manual/cert.md +++ b/docs/manual/cert.md @@ -1,13 +1,59 @@ -As more and more services move to "secure by default" behavior, it becomes important to simplify the PKI/TLS infrastructure provisioning in the lab environments. Containerlab embeds parts of [cfssl](https://github.com/cloudflare/cfssl) project to automate certificate generation and provisioning. +As more and more services move to "secure by default" behavior, it becomes important to simplify the PKI/TLS infrastructure provisioning in the lab environments. +Containerlab tries to ease the process of certificate provisioning providing the following features: -For [SR Linux](kinds/srl.md) nodes containerlab creates Certificate Authority (CA) and generates signed cert and key for each node of a lab. This makes SR Linux node to boot up with TLS profiles correctly configured and enable operation of a secured management protocol - gNMI. +1. Automated certificate provisioning for lab nodes. +2. Simplified CLI for CA and end-node keys generation. +3. Ability to use custom/external CA. + +## Automated certificate provisioning + +Automated certificate provisioning is a two-stage process. First, containerlab creates a Certificate Authority (CA) and generates a certificate and key for it, storing these artifacts in a [lab directory](conf-artifacts.md) in the `.tls` directory. Then, containerlab generates a certificate and key for each node of a lab and signs it with the CA. The signed certificate and key are then installed on the node. !!!note - For other nodes the automated TLS pipeline is not provided yet and can be addressed by contributors. + Currently, automated installation of a node certificate is implemented only for [Nokia SR Linux](kinds/srl.md). + +### CA certificate + +When generating CA certificate and key, containerlab can take in the following optional parameters: + +* `.settings.certificate-authority.key-size` - the size of the key in bytes, default is 2048 +* `.settings.certificate-authority.validity-duration` - the duration of the certificate. For example: `10m`, `1000h`. Max unit is hour. Default is `8760h` (1 year) + +### Node certificates + +The decision to generate node certificates is driven by either of the following two parameters: + +1. node kind +2. `issue` boolean parameter under `node-name.certificate` section. + +For SR Linux nodes the `issue` parameter is set to `true` and can't be changed. For other node kinds the `issue` parameter is set to `false` by default and can be [overridden](nodes.md#certificate) by the user. -Apart from automated pipeline for certificate provisioning, containerlab exposes the following commands that can create a CA and node's cert/key: +## Simplified CLI for CA and end-node keys generation + +Apart automated pipeline for certificate provisioning, containerlab exposes the following commands that can create a CA and node's cert/key: * [`tools cert ca create`](../cmd/tools/cert/ca/create.md) - creates a Certificate Authority * [`tools cert sign`](../cmd/tools/cert/sign.md) - creates certificate/key for a host and signs the certificate with CA -With these two commands users can easily create CA node certificates and secure the transport channel of various protocols. [This lab](https://clabs.netdevops.me/security/gnmitls/) demonstrates how with containerlab's help one can easily create certificates and configure Nokia SR OS to use it for secured gNMI communication. \ No newline at end of file +With these two commands users can easily create CA node certificates and secure the transport channel of various protocols. [This lab](https://clabs.netdevops.me/security/gnmitls/) demonstrates how with containerlab's help one can easily create certificates and configure Nokia SR OS to use it for secured gNMI communication. + +## External CA + +Users who require more control over the certificate generation process can use an existing external CA. Containerlab needs to be provided with the CA certificate and key. The CA certificate and key must be provided via `.settings.certificate-authority.[key]|[cert]` configuration parameters. + +```yaml +name: ext-ca +settings: + certificate-authority: + cert: /path/to/ca.crt + key: /path/to/ca.key +``` + +When using an external CA, containerlab will not generate a CA certificate and key. Instead, it will use the provided CA certificate and key to sign the node certificates. + +The paths can be provided in absolute or relative form. If the path is relative, it is relative to the directory where clab file is located. + +In addition to setting External CA files via `settings` section, users can also set the following environment variables: + +* `CLAB_CA_CERT_FILE` - path to the CA certificate +* `CLAB_CA_KEY_FILE` - path to the CA key diff --git a/docs/manual/nodes.md b/docs/manual/nodes.md index 56de934dd..39b79f232 100644 --- a/docs/manual/nodes.md +++ b/docs/manual/nodes.md @@ -708,5 +708,14 @@ topology: issue: true ``` +To configure key size and certificate validity duration use the following options: + +```yaml + certificate: + issue: true + key-size: 4096 + validity-duration: 1h +``` + [^1]: [docker runtime resources constraints](https://docs.docker.com/config/containers/resource_constraints/). [^2]: this deployment model makes two containers to use a shared network namespace, similar to a Kubernetes pod construct. diff --git a/docs/manual/topo-def-file.md b/docs/manual/topo-def-file.md index 2028befea..bc83846ff 100644 --- a/docs/manual/topo-def-file.md +++ b/docs/manual/topo-def-file.md @@ -192,7 +192,7 @@ topology: image: ghcr.io/nokia/srlinux ``` -A lot of unnecessary repetition which is eliminated when we set `srl` kind properties on kind level. +A lot of unnecessary repetition is eliminated when we set `srl` kind properties on kind level. #### Defaults @@ -213,6 +213,14 @@ topology: Now every node in this topology will have environment variable `MYENV` set to `VALUE`. +### Settings + +Global containerlab settings are defined in `settings` container. The following settings are supported: + +#### Certificate authority + +Global certificate authority settings section allows users to tune certificate management in containerlab. Refer to the [Certificate management](cert.md) doc for more details. + ## Environment variables Topology definition file may contain environment variables anywhere in the file. The syntax is the same as in the bash shell: diff --git a/nodes/default_node.go b/nodes/default_node.go index 0109e5452..59dbd2d36 100644 --- a/nodes/default_node.go +++ b/nodes/default_node.go @@ -373,7 +373,7 @@ func (d *DefaultNode) VerifyLicenseFileExists(_ context.Context) error { // provided in certInfra or generates a new one if it does not exist. func (d *DefaultNode) LoadOrGenerateCertificate(certInfra *cert.Cert, topoName string) (nodeCert *cert.Certificate, err error) { // early return if certificate generation is not required - if d.Cfg.Certificate == nil || !d.Cfg.Certificate.Issue { + if d.Cfg.Certificate == nil || !*d.Cfg.Certificate.Issue { return nil, nil } @@ -396,6 +396,8 @@ func (d *DefaultNode) LoadOrGenerateCertificate(certInfra *cert.Cert, topoName s CommonName: nodeConfig.ShortName + "." + topoName + ".io", Hosts: hosts, Organization: "containerlab", + KeySize: d.Cfg.Certificate.KeySize, + Expiry: d.Cfg.Certificate.ValidityDuration, } // Generate the cert for the node nodeCert, err = certInfra.GenerateAndSignNodeCert(certInput) diff --git a/nodes/srl/srl.go b/nodes/srl/srl.go index 16f572ee5..76f821810 100644 --- a/nodes/srl/srl.go +++ b/nodes/srl/srl.go @@ -165,12 +165,8 @@ func (s *srl) Init(cfg *types.NodeConfig, opts ...nodes.NodeOption) error { s.Cfg = cfg - // force cert generation for SR Linux nodes - if s.Cfg.Certificate == nil { - s.Cfg.Certificate = &types.CertificateConfig{ - Issue: true, - } - } + // force cert creation for srlinux nodes as they by make use of tls certificate in the default config + s.Cfg.Certificate.Issue = utils.BoolPointer(true) for _, o := range opts { o(s) diff --git a/schemas/clab.schema.json b/schemas/clab.schema.json index 7fd43ce55..b704977c9 100644 --- a/schemas/clab.schema.json +++ b/schemas/clab.schema.json @@ -493,6 +493,79 @@ "uniqueItems": true } } + }, + "certificate-authority-config": { + "type": "object", + "description": "Certificate Authority", + "markdownDescription": "", + "properties": { + "cert": { + "type": "string", + "description": "Path to the CA certificate file. If set, it is expected that the CA certificate already exists by that path" + }, + "key": { + "type": "string", + "description": "Path to the CA key file. If set, it is expected that the CA certificate already exists by that path" + }, + "key-size": { + "type": "integer", + "description": "Key size. Can only be set if the external CA certificate is not provided" + }, + "validity-duration": { + "type": "string", + "description": "CA certificate validity duration. Can only be set if the external CA certificate is not provided" + } + }, + "oneOf": [ + { + "required": [ + "cert", + "key" + ], + "not": { + "anyOf": [ + { + "required": [ + "key-size" + ] + }, + { + "required": [ + "validity-duration" + ] + } + ] + } + }, + { + "anyOf": [ + { + "required": [ + "key-size" + ] + }, + { + "required": [ + "validity-duration" + ] + } + ], + "not": { + "anyOf": [ + { + "required": [ + "cert" + ] + }, + { + "required": [ + "key" + ] + } + ] + } + } + ] } }, "type": "object", @@ -727,6 +800,16 @@ "required": [ "nodes" ] + }, + "settings": { + "description": "Global containerlab settings", + "markdownDescription": "Global [containerlab settings]()", + "type": "object", + "properties": { + "certificate-authority": { + "$ref": "#/definitions/certificate-authority-config" + } + } } }, "additionalProperties": false, diff --git a/tests/01-smoke/09-external-ca.clab.yml b/tests/01-smoke/09-external-ca.clab.yml new file mode 100644 index 000000000..498584045 --- /dev/null +++ b/tests/01-smoke/09-external-ca.clab.yml @@ -0,0 +1,18 @@ +# Copyright 2020 Nokia +# Licensed under the BSD 3-Clause License. +# SPDX-License-Identifier: BSD-3-Clause + +name: external-ca + +settings: + certificate-authority: + cert: rootCACert.pem + key: rootCAKey.pem + +topology: + nodes: + l1: + kind: linux + image: alpine:3 + certificate: + issue: true diff --git a/tests/01-smoke/09-external-ca.robot b/tests/01-smoke/09-external-ca.robot new file mode 100644 index 000000000..af09d3049 --- /dev/null +++ b/tests/01-smoke/09-external-ca.robot @@ -0,0 +1,80 @@ +*** Settings *** +Library OperatingSystem +Library String +Resource ../common.robot + +Suite Setup Run Keyword Setup +Suite Teardown Run Keyword Teardown + + +*** Variables *** +${lab-name} external-ca +${topo} ${CURDIR}/09-external-ca.clab.yml +${ca-key-file} ${CURDIR}/rootCAKey.pem +${ca-cert-file} ${CURDIR}/rootCACert.pem +${ca-keylength} 2048 +${runtime} docker + +# Node based certs files +${l1-key} ./clab-${lab-name}/.tls/l1/l1.key +${l1-cert} ./clab-${lab-name}/.tls/l1/l1.pem + + +*** Test Cases *** +Generate CA Key + ${rc} ${output} = Run And Return Rc And Output + ... openssl genrsa -out ${ca-key-file} ${ca-keylength} + Log ${output} + Should Be Equal As Integers ${rc} 0 + +Generate Certificate + ${rc} ${output} = Run And Return Rc And Output + ... openssl req -x509 -sha256 -new -nodes -key ${ca-key-file} -days 3650 -out ${ca-cert-file} -subj "/L=Internet/O=srl-labs/OU=Containerlab/CN=containerlab.dev" + Log ${output} + Should Be Equal As Integers ${rc} 0 + +Deploy ${lab-name} lab + Log ${CURDIR} + ${rc} ${output} = Run And Return Rc And Output + ... sudo -E ${CLAB_BIN} --runtime ${runtime} deploy -t ${topo} + Log ${output} + Should Be Equal As Integers ${rc} 0 + # save output to be used in next steps + Set Suite Variable ${deploy-output} ${output} + +Review Root Certificate + ${rc} ${output} = Run And Return Rc And Output + ... openssl x509 -in ${ca-cert-file} -text + Log ${output} + Should Be Equal As Integers ${rc} 0 + Should Contain ${output} Issuer: L = Internet, O = srl-labs, OU = Containerlab, CN = containerlab.dev + Should Contain ${output} Subject: L = Internet, O = srl-labs, OU = Containerlab, CN = containerlab.dev + Should Contain ${output} Public-Key: (${ca-keylength} bit) + +Node l1 cert and key files should exist + File Should Exist ${l1-cert} + File Should Exist ${l1-key} + +Review Node l1 Certificate + ${rc} ${output} = Run And Return Rc And Output + ... openssl x509 -in ${l1-cert} -text + Log ${output} + Should Be Equal As Integers ${rc} 0 + Should Contain ${output} CN = l1.external-ca.io + Should Contain ${output} Issuer: L = Internet, O = srl-labs, OU = Containerlab, CN = containerlab.dev + Should Contain ${output} Public-Key: (2048 bit) + +Verfiy node cert with CA Cert + ${rc} ${output} = Run And Return Rc And Output + ... openssl verify -CAfile ${ca-cert-file} ${l1-cert} + Log ${output} + Should Be Equal As Integers ${rc} 0 + + +*** Keywords *** +Setup + Run rm -f ${ca-key-file} ${ca-cert-file} + +Teardown + Run sudo -E ${CLAB_BIN} --runtime ${runtime} destroy -t ${topo} --cleanup + Run rm -f ${ca-key-file} ${ca-cert-file} diff --git a/tests/01-smoke/10-ca-parameter.robot b/tests/01-smoke/10-ca-parameter.robot new file mode 100644 index 000000000..72957ba99 --- /dev/null +++ b/tests/01-smoke/10-ca-parameter.robot @@ -0,0 +1,129 @@ +*** Settings *** +Library OperatingSystem +Library String +Library DateTime +Resource ../common.robot + +Suite Teardown Run Keyword Teardown + + +*** Variables *** +${lab-name} internal-ca +${topo} ${CURDIR}/10-${lab-name}.clab.yml +${ca-keysize} 512 +${l1-keysize} 512 +${l1-validity-duration} 25 hours +${l2-keysize} 1024 +${ca-validity-duration} 5 hours + +# cert files +${ca-cert-key} ./clab-${lab-name}/.tls/ca/ca.key +${ca-cert-file} ./clab-${lab-name}/.tls/ca/ca.pem +${l1-key} ./clab-${lab-name}/.tls/l1/l1.key +${l1-cert} ./clab-${lab-name}/.tls/l1/l1.pem +${l2-key} ./clab-${lab-name}/.tls/l2/l2.key +${l2-cert} ./clab-${lab-name}/.tls/l2/l2.pem +${l3-key} ./clab-${lab-name}/.tls/l3/l3.key +${l3-cert} ./clab-${lab-name}/.tls/l3/l3.pem + + +*** Test Cases *** +Deploy ${lab-name} lab + Log ${CURDIR} + ${rc} ${output} = Run And Return Rc And Output + ... sudo -E ${CLAB_BIN} --runtime ${runtime} deploy -t ${topo} + Log ${output} + Should Be Equal As Integers ${rc} 0 + # save output to be used in next steps + Set Suite Variable ${deploy-output} ${output} + +Review Root Certificate + ${rc} ${output} = Run And Return Rc And Output + ... openssl x509 -in ${ca-cert-file} -text + Log ${output} + Should Be Equal As Integers ${rc} 0 + Should Contain ${output} Issuer: C = , L = , O = containerlab, OU = , CN = ${lab-name} lab CA + Should Contain ${output} Subject: C = , L = , O = containerlab, OU = , CN = ${lab-name} lab CA + Should Contain ${output} Public-Key: (${ca-keysize} bit) + +Node l1 cert and key files should exist + File Should Exist ${l1-cert} + File Should Exist ${l1-key} + +Node l2 cert and key files should exist + File Should Exist ${l2-cert} + File Should Exist ${l2-key} + +Node l3 cert and key files should not exist + File Should Not Exist ${l3-cert} + File Should Not Exist ${l3-key} + +Review Node l1 Certificate + ${rc} ${output} = Run And Return Rc And Output + ... openssl x509 -in ${l1-cert} -text + Log ${output} + Should Be Equal As Integers ${rc} 0 + Should Contain ${output} CN = l1.${lab-name}.io + Should Contain ${output} Issuer: C = , L = , O = containerlab, OU = , CN = ${lab-name} lab CA + Should Contain ${output} Public-Key: (${l1-keysize} bit) + +Review Node l2 Certificate + ${rc} ${output} = Run And Return Rc And Output + ... openssl x509 -in ${l2-cert} -text + Log ${output} + Should Be Equal As Integers ${rc} 0 + Should Contain ${output} CN = l2.${lab-name}.io + Should Contain ${output} Issuer: C = , L = , O = containerlab, OU = , CN = ${lab-name} lab CA + Should Contain ${output} Public-Key: (${l2-keysize} bit) + +Verfiy node cert l1 with CA Cert + ${rc} ${output} = Run And Return Rc And Output + ... openssl verify -CAfile ${ca-cert-file} ${l1-cert} + Log ${output} + Should Be Equal As Integers ${rc} 0 + +Verfiy node cert l2 with CA Cert + ${rc} ${output} = Run And Return Rc And Output + ... openssl verify -CAfile ${ca-cert-file} ${l2-cert} + Log ${output} + Should Be Equal As Integers ${rc} 0 + +Verify CA Certificate Validity + ${rc} ${certificate_output} = Run And Return Rc And Output + ... openssl x509 -in ${ca-cert-file} -text + Check Certificat Validity Duration ${certificate_output} ${ca-validity-duration} + +Verify l1 Certificate Validity + ${rc} ${certificate_output} = Run And Return Rc And Output + ... openssl x509 -in ${l1-cert} -text + Check Certificat Validity Duration ${certificate_output} ${l1-validity-duration} + + +*** Keywords *** +Teardown + Run sudo -E ${CLAB_BIN} --runtime ${runtime} destroy -t ${topo} --cleanup + +Get Certificate Date + [Arguments] ${certificate_output} ${type} + ${date} = Get Regexp Matches + ... ${certificate_output} + ... Not ${type}\\W*: (\\w{3} \\d{2} \\d{2}:\\d{2}:\\d{2} \\d{4} \\w{3}) + ... 1 + [Return] ${date}[0] + +Check Certificat Validity Duration + [Arguments] ${certificate_output} ${expected_duration} + ${not_before} = Get Certificate Date ${certificate_output} Before + ${not_after} = Get Certificate Date ${certificate_output} After + + ${time_difference} = Subtract Date From Date + ... ${not_after} + ... ${not_before} + ... date1_format=%b %d %H:%M:%S %Y %Z + ... date2_format=%b %d %H:%M:%S %Y %Z + + ${verbose_time_difference} = Convert Time ${time_difference} verbose + + ${expected_verbose_time_difference} = Convert Time ${expected_duration} verbose + + Should Be Equal ${verbose_time_difference} ${expected_verbose_time_difference} diff --git a/tests/01-smoke/10-internal-ca.clab.yml b/tests/01-smoke/10-internal-ca.clab.yml new file mode 100644 index 000000000..5f2c81877 --- /dev/null +++ b/tests/01-smoke/10-internal-ca.clab.yml @@ -0,0 +1,34 @@ +# Copyright 2020 Nokia +# Licensed under the BSD 3-Clause License. +# SPDX-License-Identifier: BSD-3-Clause + +name: internal-ca + +settings: + certificate-authority: + key-size: 512 + validity-duration: 5h + +topology: + defaults: + certificate: + issue: true + key-size: 512 + nodes: + l1: + kind: linux + image: alpine:3 + certificate: + issue: true + validity-duration: 25h + l2: + kind: linux + image: alpine:3 + certificate: + issue: true + key-size: 1024 + l3: + kind: linux + image: alpine:3 + certificate: + issue: false diff --git a/types/settings.go b/types/settings.go new file mode 100644 index 000000000..c7cebbedd --- /dev/null +++ b/types/settings.go @@ -0,0 +1,22 @@ +package types + +import "time" + +// Settings is the structure for global containerlab settings. +type Settings struct { + CertificateAuthority *CertificateAuthority `yaml:"certificate-authority"` +} + +// CertificateAuthority is the structure for global containerlab certificate authority settings. +type CertificateAuthority struct { + // Cert is the path to the CA certificate file in the External CA mode of operation. + Cert string `yaml:"cert"` + // Key is the path to the CA private key file in the External CA mode of operation. + Key string `yaml:"key"` + // KeySize is the size of the CA private key in bits + // when containerlab is in charge of the CA generation. + KeySize int `yaml:"key-size"` + // ValidityDuration is the duration of the CA certificate validity + // when containerlab is in charge of the CA generation. + ValidityDuration time.Duration `yaml:"validity-duration"` +} diff --git a/types/topo_paths.go b/types/topo_paths.go index 7728cfeaf..c2f9ce18c 100644 --- a/types/topo_paths.go +++ b/types/topo_paths.go @@ -6,6 +6,8 @@ import ( "path" "path/filepath" "strings" + + "github.com/srl-labs/containerlab/utils" ) const ( @@ -29,9 +31,11 @@ var clabTmpDir = filepath.Join(os.TempDir(), ".clab") // TopoPaths creates all the required absolute paths and filenames for a topology. // generally all these paths are deduced from two main paths. The topology file path and the lab dir path. type TopoPaths struct { - topoFile string - labDir string - topoName string + topoFile string + labDir string + topoName string + externalCACertFile string // if an external CA certificate is used the path to the Cert file is stored here + externalCAKeyFile string // if an external CA certificate is used the path to the Key file is stored here } // NewTopoPaths constructs a new TopoPaths instance. @@ -69,7 +73,7 @@ func (t *TopoPaths) SetTopologyFilePath(topologyFile string) error { return nil } -// SetLabDir sets the labDir. +// SetLabDir sets the labDir foldername (no abs path, but the last element) usually the topology name. func (t *TopoPaths) SetLabDir(topologyName string) (err error) { t.topoName = topologyName // if "CLAB_LABDIR_BASE" Env Var is set, use that dir as a base @@ -86,16 +90,31 @@ func (t *TopoPaths) SetLabDir(topologyName string) (err error) { return nil } +// SetExternalCaFiles sets the paths for the cert and key files if externally generated should be used. +func (t *TopoPaths) SetExternalCaFiles(certFile, keyFile string) error { + // resolve the provided paths to external CA files + certFile = utils.ResolvePath(certFile, t.TopologyFileDir()) + keyFile = utils.ResolvePath(keyFile, t.TopologyFileDir()) + + if !utils.FileExists(certFile) { + return fmt.Errorf("external CA cert file %s does not exist", certFile) + } + + if !utils.FileExists(keyFile) { + return fmt.Errorf("external CA key file %s does not exist", keyFile) + } + + t.externalCACertFile = certFile + t.externalCAKeyFile = keyFile + + return nil +} + // TLSBaseDir returns the path of the TLS directory structure. func (t *TopoPaths) TLSBaseDir() string { return path.Join(t.labDir, tlsDir) } -// CARootCertDir returns the directory that contains the root CA certificat and key. -func (t *TopoPaths) CARootCertDir() string { - return path.Join(t.TLSBaseDir(), caDir) -} - // NodeTLSDir returns the directory that contains the certificat data for the given node. func (t *TopoPaths) NodeTLSDir(nodename string) string { return path.Join(t.TLSBaseDir(), nodename) @@ -142,13 +161,13 @@ func (t *TopoPaths) TopologyFilenameAbsPath() string { } // ClabTmpDir returns the path to the temporary directory where clab stores temporary and/or downloaded files. -func (t *TopoPaths) ClabTmpDir() string { +func (*TopoPaths) ClabTmpDir() string { return clabTmpDir } // StartupConfigDownloadFileAbsPath returns the absolute path to the startup-config file // when it is downloaded from a remote location to the clab temp directory. -func (t *TopoPaths) StartupConfigDownloadFileAbsPath(node string, postfix string) string { +func (t *TopoPaths) StartupConfigDownloadFileAbsPath(node, postfix string) string { return filepath.Join(t.ClabTmpDir(), fmt.Sprintf("%s-%s-%s", t.topoName, node, postfix)) } @@ -203,7 +222,28 @@ func (t *TopoPaths) NodeCertCSRAbsFilename(nodeName string) string { return path.Join(t.NodeTLSDir(nodeName), nodeName+CSRFileSuffix) } -// CaDir returns the dir name of the CA directory structure. -func (t *TopoPaths) CaDir() string { - return caDir +// CaCertAbsFilename returns the path to the CA cert file. +// If external CA is used, the path to the external CA cert file is returned. +// Otherwise the path to the generated CA cert file is returned. +func (t *TopoPaths) CaCertAbsFilename() string { + if t.externalCACertFile != "" { + return t.externalCACertFile + } + + return t.NodeCertAbsFilename(caDir) +} + +// CaKeyAbsFilename returns the path to the CA key file. +// If external CA is used, the path to the external CA key file is returned. +// Otherwise the path to the generated CA key file is returned. +func (t *TopoPaths) CaKeyAbsFilename() string { + if t.externalCAKeyFile != "" { + return t.externalCAKeyFile + } + + return t.NodeCertKeyAbsFilename(caDir) +} + +func (t *TopoPaths) CaCSRAbsFilename() string { + return t.NodeCertCSRAbsFilename(caDir) } diff --git a/types/topology.go b/types/topology.go index e040f8d26..74a3ab22c 100644 --- a/types/topology.go +++ b/types/topology.go @@ -540,20 +540,18 @@ func (t *Topology) GetNodeDns(name string) *DNSConfig { return nil } +// GetCertificateConfig returns the certificate configuration for the given node. func (t *Topology) GetCertificateConfig(name string) *CertificateConfig { - if ndef, ok := t.Nodes[name]; ok { - nodeCertConf := ndef.GetCertificateConfig() - if nodeCertConf != nil { - return nodeCertConf - } - - kindCertConf := t.GetKind(t.GetNodeKind(name)).GetCertificateConfig() - if kindCertConf != nil { - return kindCertConf - } - - return t.GetDefaults().GetCertificateConfig() + // default for issuing node certificates is false + cc := &CertificateConfig{ + Issue: utils.BoolPointer(false), } - return nil + // merge defaults, kind and node certificate config into the default certificate config + cc.Merge( + t.GetDefaults().GetCertificateConfig()).Merge( + t.GetKind(t.GetNodeKind(name)).GetCertificateConfig()).Merge( + t.Nodes[name].GetCertificateConfig()) + + return cc } diff --git a/types/topology_test.go b/types/topology_test.go index ec5a4886f..9244a1191 100644 --- a/types/topology_test.go +++ b/types/topology_test.go @@ -4,13 +4,10 @@ import ( "testing" "github.com/google/go-cmp/cmp" + "github.com/srl-labs/containerlab/utils" "golang.org/x/exp/slices" ) -func boolptr(b bool) *bool { - return &b -} - var topologyTestSet = map[string]struct { input *Topology want map[string]*NodeDefinition @@ -22,14 +19,14 @@ var topologyTestSet = map[string]struct { Kind: "srl", CPU: 1, Memory: "1G", - AutoRemove: boolptr(true), + AutoRemove: utils.BoolPointer(true), DNS: &DNSConfig{ Servers: []string{"1.1.1.1"}, Search: []string{"foo.com"}, Options: []string{"someopt"}, }, Certificate: &CertificateConfig{ - Issue: true, + Issue: utils.BoolPointer(true), }, }, }, @@ -39,14 +36,14 @@ var topologyTestSet = map[string]struct { Kind: "srl", CPU: 1, Memory: "1G", - AutoRemove: boolptr(true), + AutoRemove: utils.BoolPointer(true), DNS: &DNSConfig{ Servers: []string{"1.1.1.1"}, Search: []string{"foo.com"}, Options: []string{"someopt"}, }, Certificate: &CertificateConfig{ - Issue: true, + Issue: utils.BoolPointer(true), }, }, }, @@ -84,14 +81,14 @@ var topologyTestSet = map[string]struct { }, CPU: 1, Memory: "1G", - AutoRemove: boolptr(true), + AutoRemove: utils.BoolPointer(true), DNS: &DNSConfig{ Servers: []string{"8.8.8.8"}, Search: []string{"bar.com"}, Options: []string{"someotheropt"}, }, Certificate: &CertificateConfig{ - Issue: true, + Issue: utils.BoolPointer(true), }, }, }, @@ -105,7 +102,7 @@ var topologyTestSet = map[string]struct { "label2": "notv2", }, Memory: "2G", - AutoRemove: boolptr(false), + AutoRemove: utils.BoolPointer(false), DNS: &DNSConfig{ Servers: []string{"1.1.1.1"}, Search: []string{"foo.com"}, @@ -146,14 +143,14 @@ var topologyTestSet = map[string]struct { }, CPU: 1, Memory: "2G", - AutoRemove: boolptr(false), + AutoRemove: utils.BoolPointer(false), DNS: &DNSConfig{ Servers: []string{"1.1.1.1"}, Search: []string{"foo.com"}, Options: []string{"someopt"}, }, Certificate: &CertificateConfig{ - Issue: true, + Issue: utils.BoolPointer(true), }, }, }, @@ -255,6 +252,9 @@ var topologyTestSet = map[string]struct { Search: []string{"foo.com"}, Options: []string{"someopt"}, }, + Certificate: &CertificateConfig{ + Issue: utils.BoolPointer(false), + }, }, }, }, @@ -331,12 +331,15 @@ var topologyTestSet = map[string]struct { }, CPU: 1, Memory: "1G", - AutoRemove: boolptr(false), + AutoRemove: utils.BoolPointer(false), DNS: &DNSConfig{ Servers: []string{"1.1.1.1"}, Search: []string{"foo.com"}, Options: []string{"someopt"}, }, + Certificate: &CertificateConfig{ + Issue: utils.BoolPointer(false), + }, }, }, }, diff --git a/types/types.go b/types/types.go index 633730330..f067e99cb 100644 --- a/types/types.go +++ b/types/types.go @@ -7,6 +7,7 @@ package types import ( "fmt" "strings" + "time" "github.com/containernetworking/plugins/pkg/ns" "github.com/docker/go-connections/nat" @@ -324,12 +325,35 @@ type DNSConfig struct { Search []string `yaml:"search,omitempty"` } -// CertificateConfig represents the configuration of a TLS infrastructure used by a node. +// CertificateConfig represents TLS parameters set for a node. type CertificateConfig struct { // default false value indicates that the node does not use TLS - Issue bool `yaml:"issue,omitempty"` - // additional params would go here, e.g. if - // different algos would be needed or so + Issue *bool `yaml:"issue,omitempty"` + // KeySize is the size of the key in bits + KeySize int `yaml:"key-size,omitempty"` + // ValidityDuration is the duration of the certificate validity + ValidityDuration time.Duration `yaml:"validity-duration"` +} + +// Merge merges the given CertificateConfig into the current one. +func (c *CertificateConfig) Merge(x *CertificateConfig) *CertificateConfig { + if x == nil { + return c + } + + if x.ValidityDuration > 0 { + c.ValidityDuration = x.ValidityDuration + } + + if x.Issue != nil { + c.Issue = x.Issue + } + + if x.KeySize > 0 { + c.KeySize = x.KeySize + } + + return c } // PullPolicyValue represents Image pull policy values. diff --git a/utils/pointer.go b/utils/pointer.go new file mode 100644 index 000000000..37aa7dc3e --- /dev/null +++ b/utils/pointer.go @@ -0,0 +1,6 @@ +package utils + +// BoolPointer returns a pointer to a bool. +func BoolPointer(b bool) *bool { + return &b +}