Repository: skupperproject/skupper
Pull request #1690 (2024-09-27 19:53:46)

User provided certs
Allow users to provide their own certificates to be used with non-kube sites in Skupper V2.
More information is available through: cmd/bootstrap/PROVIDED_CERTIFICATES.md


Patch:
diff --git a/cmd/bootstrap/PROVIDED_CERTIFICATES.md b/cmd/bootstrap/PROVIDED_CERTIFICATES.md
new file mode 100644
index 00000000..77f2a262
--- /dev/null
+++ b/cmd/bootstrap/PROVIDED_CERTIFICATES.md
@@ -0,0 +1,177 @@
+# User provided certificates
+
+Users can provide their own certificates to be used with Skupper V2 in non kube sites
+during the bootstrap of a local site, when preparing a site bundle and even while installing
+a site bundle at a remote machine. 
+
+## How certificates are used internally
+
+During the initialization of a local non kube site or when a site bundle is being prepared,
+Skupper generates an internal Certificate Authority (CA) named `skupper-site-ca`.
+
+The certificates generated by Skupper are stored under the namespace home directory in the
+file system, using the following tree:
+
+```shell
+certificates/
+├── ca
+├── client
+└── server
+```
+
+As the CA `skupper-site-ca` is generated, it will be stored under the `certificates/ca` path
+as a directory named `skupper-site-ca`. Inside a certificate folder, the following files are
+expected: `tls.key`, `tls.crt` and `ca.crt` (when dealing with a CA, `ca.crt` and `tls.crt`
+have the same content).
+
+If a `RouterAccess` is defined as part of the non kube site definition, Skupper will also
+generate a server certificate that is valid for the provided `RouterAccess.spec.bindHost` and
+for each entry in the  `RouterAccess.spec.subjectAlternativeName` list. The respective certificate
+is signed by the CA mentioned earlier (skupper-site-ca) and it will be named according to the
+provided `spec.tlsCredentials` field, or when it is omitted, the certificate will be named as the
+RouterAccess and stored as a directory under `certificates/server`.
+
+A set of static links are also created for the provided `RouterAccess.spec.bindHost` and for each
+entry in the  `RouterAccess.spec.subjectAlternativeName` list as separate YAML files.
+
+These static links are composed by a `Link` and a `Secret`, which is basically the client certificate
+signed by `skupper-site-ca`, that will be used by other sites to establish Skupper links. The client
+certificate is also named based on the RouterAccess `spec.tlsCredentials` field or, when it is omitted,
+the certificate will be named as the RouterAccess, prefixed with `client-` and stored under
+`certificates/client`.
+
+After a site bundle has been produced, it contains the whole site definition that can be installed
+at a remote location. Thus, all certificates and static links are already part of the bundle and no
+new certificate is signed at the moment a bundle is installed.
+
+## Providing your own certificates
+
+You can provide your own certificates to be used by Skupper for site linking.
+
+The provided certificates must be placed in a similar directory tree, as shown before,
+but its path under the namespace home is located at `input/certificates` instead.
+
+Certificates placed under this path will be used by Skupper primarily and
+all the other certificates expected by Skupper, will be generated when not provided. 
+
+Depending on your goals, some certificates should be supplied at certain phases,
+for example, at the time a local site is being initialized, a bundle is being prepared
+or a bundle is being installed at a remote location.
+
+To understand it better, let's go through the main use cases and review what is the ideal
+phase that a given kind of certificate should be provided.
+
+### Using a custom CA to sign certificates
+
+If you want Skupper to generate client and server certificates signed by a custom CA,
+you will need to provide the respective certificates during:
+
+* Local site initialization time
+* Site bundle preparation time
+
+Certificates are signed by Skupper at the time a local namespace is being initialized
+or a bundle is being produced.
+
+After a site bundle has been produced, it already contains the whole definition and no
+new certificate is expected to be signed.
+
+### Using custom server and client certificates
+
+If you want a new local site to use your custom server and client certificates, you can
+provide them at any time, for example:
+
+* Local site initialization time
+* Site bundle preparation time
+* Site bundle installation time
+
+During "Local site initialization time" or "Site bundle preparation time", Skupper will detect
+that a server certificate has been provided and will inspect it to determine its subject
+alternative names and based on that, a set of static links will be created, allowing those
+links to be distributed to target sites accordingly. For the static links to remain valid,
+the expected client certificate must also be provided.
+
+If a server certificate is provided at "Site bundle installation time", Skupper will also try to
+determine its subject alternative names using the `openssl` binary (only if available) and it will
+use it to generate the static links for the bundle installation. This way, the set of static links
+available for an installed bundle will be valid for all expected target hostnames and IP addresses
+defined through the server certificate.
+
+Again, if a server certificate is provided, the respective client certificate is also expected
+so that the static links have valid client credentials.
+
+## Examples
+
+## Provide your own skupper-site-ca
+
+If you want Skupper to use your own CA certificates to generate and sign server and client
+certificates used for site linking, you can simply create the following structure under
+the namespace home of your choice, for example:
+
+```shell
+${HOME}/.local/share/skupper/namespaces/default/input/certificates/
+└── ca
+    └── skupper-site-ca
+        ├── ca.crt
+        ├── tls.crt
+        └── tls.key
+```
+
+With that, if you bootstrap a site to run in the default namespace, the CA certificates above will be
+used to sign the server and client certificates for site linking for each provided `RouterAccess`.
+
+Note that if a CA is provided at the time a site bundle is being installed, it will be detected,
+but it won't be used unless the respective namespace is re-initialized. That is because when a bundle
+is being installed, it will simply copy certificates provided by the user to be used internally, but
+no new certificate will be signed during a site bundle installation.
+
+## Server and Client certificates
+
+Server and client certificates can be provided whenever your site definition contains at least
+one `RouterAccess` (resource).
+
+The expected directory names for the server and client certificates, is determined based on the
+values of `RouterAccess.spec.tlsCredentials` (optional field), or `RouterAccess.name` (default).
+
+Supposing the value of `RouterAccess.spec.tlsCredentials` or `RouterAccess.name` (when the tlsCredentials
+field is omitted) is `my-router-access`, then the following structure, for server and client certificates,
+must be provided under the namespace home of your choice, for example:
+
+```shell
+${HOME}/.local/share/skupper/namespaces/default/input/certificates/
+├── client
+│   └── client-my-router-access
+│       ├── ca.crt
+│       ├── tls.crt
+│       └── tls.key
+└── server
+    └── my-router-access
+        ├── ca.crt
+        ├── tls.crt
+        └── tls.key
+```
+
+At bootstrap or bundle installation times, you should see a message saying that the
+user provided server and client certificates have been found.
+
+As an example, inspecting the subject alternative names of the provided server certificate above,
+and supposing it is valid for the following domain name:
+
+```shell
+X509v3 Subject Alternative Name: 
+    DNS:my.local.server.com
+```
+
+If the following domain name is not defined as being the `spec.bindHost` or as part of the
+`spec.subjectAlternativeNames` list of the `RouterAccess` resource, Skupper will also create a static
+link that uses `my.local.server.com` as the target endpoint at:
+
+```shell
+$HOME/.local/share/skupper/namespaces/default/runtime/link/link-my-router-access-my.local.server.com.yaml
+```
+
+If the respective server certificates are defined at bundle installation time, Skupper will also inspect
+the subject alternative names of the public server certificate and create the static links for each domain
+name and ip address found, only if the `openssl` binary is available.
+
+It is important that the client certificate is also provided, as all static links will be updated
+to use the provided client credentials.
diff --git a/cmd/bootstrap/README.md b/cmd/bootstrap/README.md
index b53f19ab..da7ba160 100644
--- a/cmd/bootstrap/README.md
+++ b/cmd/bootstrap/README.md
@@ -189,6 +189,14 @@ If the namespace is omitted, the "default" namespace is used.
 ./cmd/bootstrap/remove.sh [namespace]
 ```
 
+## Using custom certificates
+
+Users can provide their own certificates to be used when initializing a local site,
+when preparing a site bundle to be installed somewhere else and even during a site
+bundle installation time.
+
+More about user provided certificates can be found [here](PROVIDED_CERTIFICATES.md).
+
 ## Example
 
 Here is a very basic demonstration on how you can run the `Hello World` example
@@ -373,7 +381,7 @@ in your browser and it should work.
 ### Updating an existing installation
 
 Suppose modifications have been made to the `west` site CRs, directly at the
-namespace directory (i.e: ${HOME}/.local/share/skupper/namespaces/west/sources).
+namespace directory (i.e: ${HOME}/.local/share/skupper/namespaces/west/input/sources).
 
 To re-initialize the west site, run:
 
diff --git a/cmd/bootstrap/bootstrap.go b/cmd/bootstrap/bootstrap.go
index 4992d067..ebfb872e 100644
--- a/cmd/bootstrap/bootstrap.go
+++ b/cmd/bootstrap/bootstrap.go
@@ -10,6 +10,7 @@ import (
 
 	"github.com/skupperproject/skupper/api/types"
 	internalbundle "github.com/skupperproject/skupper/internal/nonkube/bundle"
+	internalutils "github.com/skupperproject/skupper/internal/utils"
 	"github.com/skupperproject/skupper/pkg/config"
 	"github.com/skupperproject/skupper/pkg/nonkube/api"
 	"github.com/skupperproject/skupper/pkg/nonkube/bundle"
@@ -80,7 +81,6 @@ func main() {
 		fmt.Println(version.Version)
 		os.Exit(0)
 	}
-	// if user overrides and force empty, use it as default
 	if inputPath != "" {
 		var err error
 		inputPath, err = filepath.Abs(inputPath)
@@ -150,16 +150,27 @@ func main() {
 	if namespace == "" {
 		namespace = "default"
 	}
+	existingPath := api.GetInternalOutputPath(namespace, api.InputSiteStatePath)
+	inputSourcesDefined := false
+	if _, err := os.Stat(existingPath); err == nil {
+		dirReader := new(internalutils.DirectoryReader)
+		filesFound, _ := dirReader.ReadDir(existingPath, nil)
+		inputSourcesDefined = len(filesFound) > 0
+	}
 	if inputPath == "" {
 		// when input path is empty, but a namespace is provided, try to reload an existing site definition
-		existingPath := api.GetInternalOutputPath(namespace, api.LoadedSiteStatePath)
-		if _, err := os.Stat(existingPath); err == nil {
+		if inputSourcesDefined {
 			inputPath = existingPath
 			fmt.Printf("Sources will consumed from namespace %q\n", namespace)
 		} else {
 			fmt.Printf("Input path has not been provided and namespace %s does not exist\n", namespace)
+			fmt.Printf("No sources found at: %s\n", path.Join(api.GetHostNamespaceHome(namespace), string(api.InputSiteStatePath)))
 			os.Exit(1)
 		}
+	} else if inputSourcesDefined {
+		fmt.Printf("Input path has been provided, but namespace %s has input sources defined at:\n", namespace)
+		fmt.Printf("%s\n", path.Join(api.GetHostNamespaceHome(namespace), string(api.InputSiteStatePath)))
+		os.Exit(1)
 	}
 
 	// if namespace already exists, fail if force is not set
@@ -195,10 +206,7 @@ func main() {
 	if !isBundle {
 		fmt.Printf("Platform: %s\n", platform)
 		tokenPath := api.GetInternalOutputPath(siteState.Site.Namespace, api.RuntimeTokenPath)
-		hostTokenPath, err := api.GetHostSiteInternalPath(siteState.Site, api.RuntimeTokenPath)
-		if err != nil {
-			fmt.Println("Failed to get site's static links path:", err)
-		}
+		hostTokenPath := api.GetHostSiteInternalPath(siteState.Site, api.RuntimeTokenPath)
 		tokens, _ := os.ReadDir(tokenPath)
 		for _, token := range tokens {
 			if !token.IsDir() {
@@ -206,13 +214,10 @@ func main() {
 				break
 			}
 		}
-		sourcesPath, _ := api.GetHostSiteInternalPath(siteState.Site, api.LoadedSiteStatePath)
+		sourcesPath := api.GetHostSiteInternalPath(siteState.Site, api.InputSiteStatePath)
 		fmt.Printf("Definition is available at: %s\n", sourcesPath)
 	} else {
-		siteHome, err := api.GetHostBundlesPath()
-		if err != nil {
-			fmt.Println("Failed to get site bundle base directory:", err)
-		}
+		siteHome := api.GetHostBundlesPath()
 		installationFile := path.Join(siteHome, fmt.Sprintf("skupper-install-%s.sh", siteState.Site.Name))
 		if internalbundle.GetBundleStrategy(bundleStrategy) == string(internalbundle.BundleStrategyTarball) {
 			installationFile = path.Join(siteHome, fmt.Sprintf("skupper-install-%s.tar.gz", siteState.Site.Name))
@@ -227,7 +232,7 @@ func bootstrap(inputPath string, namespace string, bundleStrategy string) (*api.
 	var siteStateLoader api.SiteStateLoader
 	var reloadExisting bool
 	isBundle := bundleStrategy != ""
-	sourcesPath := api.GetInternalOutputPath(namespace, api.LoadedSiteStatePath)
+	sourcesPath := api.GetInternalOutputPath(namespace, api.InputSiteStatePath)
 	_, err := os.Stat(sourcesPath)
 	if !isBundle && err == nil {
 		reloadExisting = true
diff --git a/internal/nonkube/bundle/install.sh.template b/internal/nonkube/bundle/install.sh.template
index cb786b85..4e279f87 100644
--- a/internal/nonkube/bundle/install.sh.template
+++ b/internal/nonkube/bundle/install.sh.template
@@ -147,6 +147,9 @@ create_service() {
 }
 
 remove_service() {
+    # if systemd is not available, skip it
+    ${SYSTEMCTL} list-units > /dev/null 2>&1 || return
+
     service="skupper-${NAMESPACE}.service"
     ${SYSTEMCTL} stop "${service}"
     ${SYSTEMCTL} disable "${service}"
@@ -239,24 +242,30 @@ create_containers() {
 }
 
 create_site() {
-    if [ -d "${NAMESPACES_PATH}/${NAMESPACE}" ]; then
+    if [ -d "${NAMESPACES_PATH:?}/${NAMESPACE:?}/runtime" ]; then
         echo "Failed: namespace \"${NAMESPACE}\" is already defined"
         echo "Location: ${NAMESPACES_PATH}/${NAMESPACE}"
         exit 1
     fi
 
+    if [ -d "${NAMESPACES_PATH:?}/${NAMESPACE:?}/input/sources" ]; then
+        echo "Failed: namespace \"${NAMESPACE}\" already contains input sources"
+        echo "Location: ${NAMESPACES_PATH:?}/${NAMESPACE:?}/input/sources"
+        exit 1
+    fi
+
     echo "Skupper site bundle installation"
     echo "Namespace : ${NAMESPACE}"
     echo "Site name : ${SITE_NAME}"
     echo "Platform  : ${SKUPPER_PLATFORM}"
-    echo "Definition: ${NAMESPACES_PATH:?}/${NAMESPACE:?}/sources"
+    echo "Definition: ${NAMESPACES_PATH:?}/${NAMESPACE:?}/input/sources"
     echo "Version   : ${VERSION}"
 
     # Create base directory tree
-    mkdir -p "${NAMESPACES_PATH}"
+    mkdir -p "${NAMESPACES_PATH}/${NAMESPACE}"
 
     # Installing site definition files
-    cp -rf "./${SOURCE_NAMESPACE}" "${NAMESPACES_PATH}/${NAMESPACE}"
+    cp -rf "./${SOURCE_NAMESPACE}"/* "${NAMESPACES_PATH}/${NAMESPACE}/"
 
     # Creating platform.yaml
     echo "platform: ${SKUPPER_PLATFORM}" > "${PLATFORM_FILE}"
@@ -282,7 +291,7 @@ create_site() {
 
 sanity_check() {
     required_fields="SITE_NAME SOURCE_NAMESPACE NAMESPACE SKUPPER_OUTPUT_PATH SERVICE_DIR NAMESPACES_PATH SKUPPER_PLATFORM"
-    required_commands="python sed find grep xargs tar getent id ${PLATFORM_COMMAND}"
+    required_commands="python sed find grep wc xargs tar getent echo cp id cut ls rm mkdir ${PLATFORM_COMMAND}"
 
     for field_name in ${required_fields}; do
         eval [ -n "\${${field_name}}" ] || exit_error "Internal error: required field ${field_name} not defined"
@@ -294,6 +303,125 @@ sanity_check() {
     done
 }
 
+list_valid_certificates() {
+    base_path="${1}"
+    cd "${base_path}"
+    for dir in $(find ./* -type d | cut -c 3-); do
+        [ -f "${dir}/tls.key" ] && [ -f "${dir}/tls.crt" ] && [ -f "${dir}/ca.crt" ] && echo "$dir"
+    done
+}
+
+handle_provided_certificates() {
+    export USER_CERTIFICATES_PATH="${NAMESPACES_PATH}/${NAMESPACE}/input/certificates"
+    [ ! -d "${USER_CERTIFICATES_PATH}" ] && return
+    cas=""
+    clients=""
+    servers=""
+    if [ -d "${USER_CERTIFICATES_PATH}/ca" ]; then
+        cas=$(list_valid_certificates "${USER_CERTIFICATES_PATH}/ca")
+    fi
+    if [ -d "${USER_CERTIFICATES_PATH}/client" ]; then
+        clients=$(list_valid_certificates "${USER_CERTIFICATES_PATH}/client")
+    fi
+    if [ -d "${USER_CERTIFICATES_PATH}/server" ]; then
+        servers=$(list_valid_certificates "${USER_CERTIFICATES_PATH}/server")
+    fi
+    if [ -z "${cas}" ] && [ -z "${clients}" ] && [ -z "${servers}" ]; then
+        return
+    fi
+    echo "User provided certificates found:"
+    if [ -n "${cas}" ]; then
+        echo "- CA certificates:"
+        for ca in ${cas}; do
+            echo "  - ${ca}"
+        done
+    fi
+    if [ -n "${clients}" ]; then
+        echo "- Client certificates:"
+        for client in ${clients}; do
+            echo "  - ${client}"
+        done
+    fi
+    if [ -n "${servers}" ]; then
+        echo "- Server certificates:"
+        for server in ${servers}; do
+            echo "  - ${server}"
+        done
+    fi
+    cp -r "${USER_CERTIFICATES_PATH}"/* "./${SOURCE_NAMESPACE}/certificates"
+    update_static_links
+    generate_static_links_for_sans
+}
+
+update_static_links() {
+    DIR="./${SOURCE_NAMESPACE}"
+    export USER_CERTIFICATES_PATH="${NAMESPACES_PATH}/${NAMESPACE}/input/certificates"
+    router_access_files=$(find "${DIR:?}/sources/routerAccesses" -type f)
+
+    for ra in ${router_access_files}; do
+        ra_name=$(grep name: "${ra}" | grep -v '\- name:' | awk '{print $NF}' | sed 's/"//g')
+        tls_credential=$(grep tlsCredentials: "${ra}" | awk '{print $NF}' | sed 's/"//g')
+        bind_host=$(grep bindHost: "${ra}" | awk '{print $NF}' | sed 's/"//g')
+        client_cert_path="${USER_CERTIFICATES_PATH}/client/client-${tls_credential:-${ra_name}}"
+
+        # no client certificate provided, skip
+        [ ! -d "${client_cert_path}" ] && continue
+
+        found=true
+        for file in tls.key tls.crt ca.crt; do
+            if [ ! -f "${client_cert_path}/${file}" ]; then
+                found=false
+                break
+            fi
+        done
+        ! ${found} && continue
+
+        # client certs found, reading client certificates
+        ca_crt=$(base64 -w0 "${client_cert_path}/ca.crt")
+        tls_crt=$(base64 -w0 "${client_cert_path}/tls.crt")
+        tls_key=$(base64 -w0 "${client_cert_path}/tls.key")
+        # find all static links generated for the given RouterAccess
+        static_link_files=$(find "${DIR:?}/runtime/link" -type f -name "link-${ra_name}-*")
+        for link in ${static_link_files}; do
+            echo "- Updating client credentials on static link: ${link}"
+            sed -i "s/tls.key: .*/tls.key: ${tls_key}/g" "${link}"
+            sed -i "s/tls.crt: .*/tls.crt: ${tls_crt}/g" "${link}"
+            sed -i "s/ca.crt: .*/ca.crt: ${ca_crt}/g" "${link}"
+        done
+    done
+}
+
+generate_static_links_for_sans() {
+    DIR="./${SOURCE_NAMESPACE}"
+
+    # if openssl is present, discover SANs and create missing static links
+    if ! command -v openssl > /dev/null 2>&1; then
+        return
+    fi
+
+    router_access_files=$(find "${DIR:?}/sources/routerAccesses" -type f)
+
+    for ra in ${router_access_files}; do
+        ra_name=$(grep name: "${ra}" | grep -v '\- name:' | awk '{print $NF}' | sed 's/"//g')
+        tls_credential=$(grep tlsCredentials: "${ra}" | awk '{print $NF}' | sed 's/"//g')
+        bind_host=$(grep bindHost: "${ra}" | awk '{print $NF}' | sed 's/"//g')
+
+        # discovering SANS
+        server_cert="${DIR}/certificates/server/${tls_credential:-${ra_name}}/tls.crt"
+        [ ! -f "${server_cert}" ] && continue
+        subjects="$(openssl x509 -noout -ext subjectAltName -in "${server_cert}" |grep -v 'Subject Alternative Name:' | \
+                    sed 's/ //g;s/,/\n/g' |  awk -F: '{if ($1 == "DNS" || $1 == "IPAddress") print $2}')"
+        [ -z "${subjects}" ] && continue
+        for subject in ${subjects}; do
+            link_file="${DIR}/runtime/link/link-${ra_name}-${subject}.yaml"
+            [ -f "${link_file}" ] && continue
+            echo "- Creating static link for new server subject found: ${subject}"
+            cp "${DIR}/runtime/link/link-${ra_name}-${bind_host:-127.0.0.1}.yaml" "${DIR}/runtime/link/link-${ra_name}-${subject}.yaml"
+            sed -i "s/host: .*/host: ${subject}/g" "${DIR}/runtime/link/link-${ra_name}-${subject}.yaml"
+        done
+    done
+}
+
 main() {
     # validate provided options
     parse_opts "$@"
@@ -304,7 +432,11 @@ main() {
         remove_site
         remove_service
         return
-    elif ${DUMP_TOKENS}; then
+    fi
+
+    handle_provided_certificates
+
+    if ${DUMP_TOKENS}; then
         dump_tokens
         return
     fi
diff --git a/internal/nonkube/client/fs/path_provider.go b/internal/nonkube/client/fs/path_provider.go
index 45b952c7..5851fdf0 100644
--- a/internal/nonkube/client/fs/path_provider.go
+++ b/internal/nonkube/client/fs/path_provider.go
@@ -7,7 +7,7 @@ type PathProvider struct {
 }
 
 func (p *PathProvider) getDefaultNamespace() string {
-	return ".local/share/skupper/namespaces/default/sources"
+	return ".local/share/skupper/namespaces/default/input/sources"
 }
 
 func (p *PathProvider) GetNamespace() string {
@@ -15,5 +15,5 @@ func (p *PathProvider) GetNamespace() string {
 	if p.Namespace == "" {
 		return p.getDefaultNamespace()
 	}
-	return fmt.Sprintf(".local/share/skupper/namespaces/%s/sources", p.Namespace)
+	return fmt.Sprintf(".local/share/skupper/namespaces/%s/input/sources", p.Namespace)
 }
diff --git a/pkg/nonkube/api/environment.go b/pkg/nonkube/api/environment.go
index ac41b26b..f2b99063 100644
--- a/pkg/nonkube/api/environment.go
+++ b/pkg/nonkube/api/environment.go
@@ -12,15 +12,19 @@ type InternalPath string
 type InternalPathProvider func(namespace string, internalPath InternalPath) string
 
 const (
-	ConfigRouterPath       InternalPath = "config/router"
-	CertificatesCaPath     InternalPath = "certificates/ca"
-	CertificatesClientPath InternalPath = "certificates/client"
-	CertificatesServerPath InternalPath = "certificates/server"
-	CertificatesLinkPath   InternalPath = "certificates/link"
-	LoadedSiteStatePath    InternalPath = "sources"
-	RuntimeSiteStatePath   InternalPath = "runtime/state"
-	RuntimeTokenPath       InternalPath = "runtime/link"
-	RuntimeScriptsPath     InternalPath = "runtime/scripts"
+	ConfigRouterPath            InternalPath = "config/router"
+	CertificatesCaPath          InternalPath = "certificates/ca"
+	CertificatesClientPath      InternalPath = "certificates/client"
+	CertificatesServerPath      InternalPath = "certificates/server"
+	CertificatesLinkPath        InternalPath = "certificates/link"
+	InputCertificatesCaPath     InternalPath = "input/certificates/ca"
+	InputCertificatesClientPath InternalPath = "input/certificates/client"
+	InputCertificatesServerPath InternalPath = "input/certificates/server"
+	InputSiteStatePath          InternalPath = "input/sources"
+	LoadedSiteStatePath         InternalPath = "sources"
+	RuntimeSiteStatePath        InternalPath = "runtime/state"
+	RuntimeTokenPath            InternalPath = "runtime/link"
+	RuntimeScriptsPath          InternalPath = "runtime/scripts"
 )
 
 type IdGetter func() int
@@ -63,40 +67,42 @@ func GetRuntimeDir() string {
 // GetHostDataHome returns the value of the SKUPPER_OUTPUT_PATH environment
 // variable when running via container or the result of GetDataHome() otherwise.
 // This is only useful during the bootstrap process.
-func GetHostDataHome() (string, error) {
+func GetHostDataHome() string {
 	// If container provides SKUPPER_OUTPUT_PATH use it
 	if os.Getenv("SKUPPER_OUTPUT_PATH") != "" {
-		return os.Getenv("SKUPPER_OUTPUT_PATH"), nil
+		return os.Getenv("SKUPPER_OUTPUT_PATH")
 	}
-	return GetDataHome(), nil
+	return GetDataHome()
 }
 
-func GetHostSiteHome(site *v1alpha1.Site) (string, error) {
-	dataHome, err := GetHostDataHome()
-	if err != nil {
-		return "", err
-	}
+func GetHostSiteHome(site *v1alpha1.Site) string {
+	dataHome := GetHostDataHome()
 	ns := site.Namespace
 	if ns == "" {
 		ns = "default"
 	}
-	return path.Join(dataHome, "namespaces", ns), nil
+	return path.Join(dataHome, "namespaces", ns)
+}
+
+func GetHostNamespaceHome(ns string) string {
+	dataHome := GetHostDataHome()
+	if ns == "" {
+		ns = "default"
+	}
+	return path.Join(dataHome, "namespaces", ns)
 }
 
-func GetHostNamespacesPath() (string, error) {
+func GetHostNamespacesPath() string {
 	return getHostPath("namespaces")
 }
 
-func GetHostBundlesPath() (string, error) {
+func GetHostBundlesPath() string {
 	return getHostPath("bundles")
 }
 
-func getHostPath(basePath string) (string, error) {
-	dataHome, err := GetHostDataHome()
-	if err != nil {
-		return "", err
-	}
-	return path.Join(dataHome, basePath), nil
+func getHostPath(basePath string) string {
+	dataHome := GetHostDataHome()
+	return path.Join(dataHome, basePath)
 }
 
 func GetCustomSiteHome(site *v1alpha1.Site, customBaseDir string) string {
@@ -115,12 +121,9 @@ func getCustomSiteHome(site *v1alpha1.Site, customBaseDir string, basePath strin
 	return path.Join(customBaseDir, basePath, ns)
 }
 
-func GetHostSiteInternalPath(site *v1alpha1.Site, internalPath InternalPath) (string, error) {
-	dataHome, err := GetHostSiteHome(site)
-	if err != nil {
-		return "", err
-	}
-	return path.Join(dataHome, string(internalPath)), nil
+func GetHostSiteInternalPath(site *v1alpha1.Site, internalPath InternalPath) string {
+	dataHome := GetHostSiteHome(site)
+	return path.Join(dataHome, string(internalPath))
 }
 
 func IsRunningInContainer() bool {
diff --git a/pkg/nonkube/api/site_state.go b/pkg/nonkube/api/site_state.go
index 33712fca..dc78d819 100644
--- a/pkg/nonkube/api/site_state.go
+++ b/pkg/nonkube/api/site_state.go
@@ -153,6 +153,12 @@ func (s *SiteState) CreateLinkAccessesCertificates() {
 		if linkAccess.Spec.Issuer != "" {
 			linkAccessCaName = linkAccess.Spec.Issuer
 		}
+		if linkAccessCaName != caName {
+			s.Certificates[linkAccessCaName] = s.newCertificate(linkAccessCaName, &v1alpha1.CertificateSpec{
+				Subject: linkAccessCaName,
+				Signing: true,
+			})
+		}
 		certName := name
 		if linkAccess.Spec.TlsCredentials != "" {
 			certName = linkAccess.Spec.TlsCredentials
diff --git a/pkg/nonkube/api/token.go b/pkg/nonkube/api/token.go
index 8dcf575a..ac6c68b0 100644
--- a/pkg/nonkube/api/token.go
+++ b/pkg/nonkube/api/token.go
@@ -3,7 +3,10 @@ package api
 import (
 	"bufio"
 	"bytes"
+	"crypto/x509"
+	"encoding/pem"
 	"fmt"
+	"slices"
 	"strconv"
 
 	"github.com/skupperproject/skupper/pkg/apis/skupper/v1alpha1"
@@ -41,7 +44,7 @@ func (t *Token) Marshal() ([]byte, error) {
 	return buffer.Bytes(), nil
 }
 
-func CreateTokens(routerAccess v1alpha1.RouterAccess, secret v1.Secret) []*Token {
+func CreateTokens(routerAccess v1alpha1.RouterAccess, serverSecret v1.Secret, clientSecret v1.Secret) []*Token {
 	var tokens []*Token
 	interRouter := 0
 	edge := 0
@@ -60,7 +63,7 @@ func CreateTokens(routerAccess v1alpha1.RouterAccess, secret v1.Secret) []*Token
 		name := routerAccess.Name
 		linkName := fmt.Sprintf("link-%s", name)
 		// adjusting name to match the standard used by pkg/site/link.go
-		secret.Name = fmt.Sprintf("link-%s", name)
+		clientSecret.Name = fmt.Sprintf("link-%s", name)
 		token := &Token{
 			Links: []*v1alpha1.Link{
 				{
@@ -72,12 +75,12 @@ func CreateTokens(routerAccess v1alpha1.RouterAccess, secret v1.Secret) []*Token
 						Name: linkName,
 					},
 					Spec: v1alpha1.LinkSpec{
-						TlsCredentials: secret.Name,
+						TlsCredentials: clientSecret.Name,
 						Cost:           1,
 					},
 				},
 			},
-			Secret: &secret,
+			Secret: &clientSecret,
 		}
 		var endpoints []v1alpha1.Endpoint
 		if interRouter > 0 {
@@ -102,8 +105,29 @@ func CreateTokens(routerAccess v1alpha1.RouterAccess, secret v1.Secret) []*Token
 	}
 	var hosts []string
 	hosts = append(hosts, utils.DefaultStr(routerAccess.Spec.BindHost, "127.0.0.1"))
-	if len(routerAccess.Spec.SubjectAlternativeNames) > 0 {
-		hosts = append(hosts, routerAccess.Spec.SubjectAlternativeNames...)
+	// reading SANs from server certificate
+	serverCertificateData := serverSecret.Data["tls.crt"]
+	serverCertificateBlk, _ := pem.Decode(serverCertificateData)
+	if serverCertificateBlk != nil {
+		serverCertificate, err := x509.ParseCertificate(serverCertificateBlk.Bytes)
+		if err == nil {
+			for _, ipAddr := range serverCertificate.IPAddresses {
+				if ipAddr.String() != "" && !slices.Contains(hosts, ipAddr.String()) {
+					hosts = append(hosts, ipAddr.String())
+				}
+			}
+			for _, dnsName := range serverCertificate.DNSNames {
+				if dnsName != "" && !slices.Contains(hosts, dnsName) {
+					hosts = append(hosts, dnsName)
+				}
+			}
+		}
+	}
+	// if no server certificate provided, use routerAccess.spec.subjectAlternativeNames
+	if len(hosts) == 1 {
+		if len(routerAccess.Spec.SubjectAlternativeNames) > 0 {
+			hosts = append(hosts, routerAccess.Spec.SubjectAlternativeNames...)
+		}
 	}
 	for _, host := range hosts {
 		tokens = append(tokens, createToken(host))
diff --git a/pkg/nonkube/common/fs_config_renderer.go b/pkg/nonkube/common/fs_config_renderer.go
index 7b2ad3f4..f2c5e03a 100644
--- a/pkg/nonkube/common/fs_config_renderer.go
+++ b/pkg/nonkube/common/fs_config_renderer.go
@@ -26,6 +26,10 @@ var (
 		api.CertificatesClientPath,
 		api.CertificatesServerPath,
 		api.CertificatesLinkPath,
+		api.InputCertificatesCaPath,
+		api.InputCertificatesClientPath,
+		api.InputCertificatesServerPath,
+		api.InputSiteStatePath,
 		api.LoadedSiteStatePath,
 		api.RuntimeSiteStatePath,
 		api.RuntimeTokenPath,
@@ -39,6 +43,7 @@ var (
 		api.RuntimeSiteStatePath,
 		api.RuntimeTokenPath,
 		api.RuntimeScriptsPath,
+		api.LoadedSiteStatePath,
 	}
 )
 
@@ -147,38 +152,44 @@ func (c *FileSystemConfigurationRenderer) GetOutputPath(siteState *api.SiteState
 	return defaultOutputPathProvider(siteState.Site.Namespace)
 }
 
+func (c *FileSystemConfigurationRenderer) GetInputPath(siteState *api.SiteState) string {
+	var customSiteHomeProvider = api.GetCustomSiteHome
+	var defaultOutputPathProvider = api.GetDefaultOutputPath
+	if c.customOutputPath != "" {
+		return path.Join(customSiteHomeProvider(siteState.Site, c.customOutputPath), "input")
+	}
+	return path.Join(defaultOutputPathProvider(siteState.Site.Namespace), "input")
+}
+
 func (c *FileSystemConfigurationRenderer) MarshalSiteStates(loadedSiteState, runtimeSiteState *api.SiteState) error {
 	if loadedSiteState != nil {
 		outputPath := c.GetOutputPath(loadedSiteState)
 		sourcesPath := path.Join(outputPath, string(api.LoadedSiteStatePath))
-		existingSources, _ := new(utils.DirectoryReader).ReadDir(sourcesPath, nil)
+		inputSourcesPath := path.Join(outputPath, string(api.InputSiteStatePath))
+		existingSources, _ := new(utils.DirectoryReader).ReadDir(inputSourcesPath, nil)
 		// when sources are already defined, we back them up
 		if len(existingSources) > 0 {
 			tb := utils.NewTarball()
-			err := tb.AddFiles(sourcesPath)
+			err := tb.AddFiles(inputSourcesPath)
 			if err != nil {
 				return fmt.Errorf("unable to backup existing sources: %s", err)
 			}
-			tbFile := path.Join(outputPath, "sources.backup.tar.gz")
+			tbFile := path.Join(outputPath, "input", "sources.backup.tar.gz")
 			err = tb.Save(tbFile)
 			if err != nil {
 				return fmt.Errorf("unable to backup sources.backup.tar.gz: %s", err)
 			}
 			err = os.RemoveAll(sourcesPath)
 			if err != nil {
-				return fmt.Errorf("unable to remove old sources: %s", err)
+				return fmt.Errorf("unable to remove former loaded state: %s", err)
 			}
 			if err = os.Mkdir(sourcesPath, 0755); err != nil {
-				defer func() {
-					if err != nil {
-						if restoreErr := tb.Extract(tbFile, outputPath); restoreErr != nil {
-							fmt.Printf("unable to restore sources.backup.tar.gz: %s", restoreErr)
-							return
-						}
-					}
-				}()
 				return fmt.Errorf("unable to recreate sources directory %s: %s", sourcesPath, err)
 			}
+		} else {
+			if err := api.MarshalSiteState(*loadedSiteState, inputSourcesPath); err != nil {
+				return err
+			}
 		}
 		if err := api.MarshalSiteState(*loadedSiteState, sourcesPath); err != nil {
 			return err
@@ -239,11 +250,15 @@ func (c *FileSystemConfigurationRenderer) createTokens(siteState *api.SiteState)
 			certName = linkAccess.Spec.TlsCredentials
 		}
 		secretName := fmt.Sprintf("client-%s", certName)
+		serverSecret, err := c.loadCertAsSecret(siteState, "server", certName)
+		if err != nil {
+			return fmt.Errorf("unable to load server secret %s: %w", certName, err)
+		}
 		secret, err := c.loadClientSecret(siteState, secretName)
 		if err != nil {
 			return fmt.Errorf("unable to load client secret %s: %v", secretName, err)
 		}
-		routerTokens := api.CreateTokens(*linkAccess, *secret)
+		routerTokens := api.CreateTokens(*linkAccess, *serverSecret, *secret)
 		// routerAccess is valid (inter-router and edge endpoints defined)
 		if len(routerTokens) > 0 {
 			tokens = append(tokens, routerTokens...)
@@ -329,8 +344,17 @@ func (c *FileSystemConfigurationRenderer) createTlsCertificates(siteState *api.S
 			continue
 		}
 		secret := certs.GenerateCASecret(name, certificate.Spec.Subject)
+
+		ignoreExisting := true
+		userCaSecret, err := c.loadUserCertAsSecret(siteState, "ca", name)
+		if userCaSecret != nil && err == nil {
+			// override with user provided CA
+			ignoreExisting = false
+			secret = *userCaSecret
+			fmt.Printf("-> User provided CA found: %s\n", name)
+		}
 		caPath := path.Join(outputPath, string(api.CertificatesCaPath), name)
-		err = writeSecretFilesIgnore(caPath, &secret, true)
+		err = writeSecretFilesIgnore(caPath, &secret, ignoreExisting)
 		if err != nil {
 			return err
 		}
@@ -359,6 +383,12 @@ func (c *FileSystemConfigurationRenderer) createTlsCertificates(siteState *api.S
 		} else {
 			continue
 		}
+		userSecret, err := c.loadUserCertAsSecret(siteState, purpose, name)
+		if userSecret != nil && err == nil {
+			// override with user provided secret
+			secret = *userSecret
+			fmt.Printf("-> User provided %s certificate found: %s\n", purpose, name)
+		}
 		certPath := path.Join(outputPath, "certificates", purpose, name)
 		err = writeSecretFiles(certPath, &secret)
 		if err != nil {
@@ -424,7 +454,36 @@ func (c *FileSystemConfigurationRenderer) loadClientSecret(siteState *api.SiteSt
 
 func (c *FileSystemConfigurationRenderer) loadCertAsSecret(siteState *api.SiteState, purpose, name string) (*corev1.Secret, error) {
 	outputPath := c.GetOutputPath(siteState)
-	certPath := path.Join(outputPath, fmt.Sprintf("certificates/%s", purpose), name)
+	return c.loadCertAsSecretFrom(outputPath, siteState, purpose, name)
+}
+
+func (c *FileSystemConfigurationRenderer) loadUserCertAsSecret(siteState *api.SiteState, purpose, name string) (*corev1.Secret, error) {
+	userInputPath := c.GetInputPath(siteState)
+	secret, err := c.loadCertAsSecretFrom(userInputPath, siteState, purpose, name)
+	if err != nil {
+		if os.IsNotExist(err) {
+			return nil, nil
+		}
+		return nil, err
+	}
+	keys := map[string]bool{
+		"ca.crt":  false,
+		"tls.key": false,
+		"tls.crt": false,
+	}
+	for key := range secret.Data {
+		keys[key] = true
+	}
+	for _, hasKey := range keys {
+		if !hasKey {
+			return nil, fmt.Errorf("secret %q does not contain required keys: %v", name, keys)
+		}
+	}
+	return secret, nil
+}
+
+func (c *FileSystemConfigurationRenderer) loadCertAsSecretFrom(basePath string, siteState *api.SiteState, purpose, name string) (*corev1.Secret, error) {
+	certPath := path.Join(basePath, "certificates", purpose, name)
 	var secret *corev1.Secret
 	certDir, err := os.Open(certPath)
 	if err != nil {
diff --git a/pkg/nonkube/common/systemd.go b/pkg/nonkube/common/systemd.go
index 66dca123..f7b9755a 100644
--- a/pkg/nonkube/common/systemd.go
+++ b/pkg/nonkube/common/systemd.go
@@ -50,10 +50,7 @@ type systemdServiceInfo struct {
 
 func NewSystemdServiceInfo(siteState *api.SiteState, platform string) (SystemdService, error) {
 	site := siteState.Site
-	siteHomePath, err := api.GetHostSiteHome(site)
-	if err != nil {
-		return nil, err
-	}
+	siteHomePath := api.GetHostSiteHome(site)
 	siteScriptPath := path.Join(siteHomePath, string(api.RuntimeScriptsPath))
 	siteConfigPath := path.Join(siteHomePath, string(api.ConfigRouterPath))
 	namespace := site.Namespace
diff --git a/pkg/nonkube/compat/site_state_renderer.go b/pkg/nonkube/compat/site_state_renderer.go
index 4a54850d..9b250bb3 100644
--- a/pkg/nonkube/compat/site_state_renderer.go
+++ b/pkg/nonkube/compat/site_state_renderer.go
@@ -177,10 +177,7 @@ func (s *SiteStateRenderer) cleanupExistingNamespace(siteState *api.SiteState) e
 }
 
 func (s *SiteStateRenderer) prepareContainers() error {
-	siteConfigPath, err := api.GetHostSiteHome(s.siteState.Site)
-	if err != nil {
-		return err
-	}
+	siteConfigPath := api.GetHostSiteHome(s.siteState.Site)
 	s.containers = make(map[string]container.Container)
 	s.containers[types.RouterComponent] = container.Container{
 		Name:  fmt.Sprintf("%s-skupper-router", s.siteState.GetNamespace()),
diff --git a/pkg/nonkube/systemd/site_state_renderer.go b/pkg/nonkube/systemd/site_state_renderer.go
index 124cd66a..6e0af91a 100644
--- a/pkg/nonkube/systemd/site_state_renderer.go
+++ b/pkg/nonkube/systemd/site_state_renderer.go
@@ -77,10 +77,7 @@ func (s *SiteStateRenderer) Render(loadedSiteState *api.SiteState, reload bool)
 	s.siteState.CreateLinkAccessesCertificates()
 	s.siteState.CreateBridgeCertificates()
 	// rendering non-kube configuration files and certificates
-	siteHome, err := api.GetHostSiteHome(s.siteState.Site)
-	if err != nil {
-		return fmt.Errorf("failed to get site home: %w", err)
-	}
+	siteHome := api.GetHostSiteHome(s.siteState.Site)
 	s.configRenderer = &common.FileSystemConfigurationRenderer{
 		SslProfileBasePath: siteHome,
 		Platform:           string(types.PlatformSystemd),


Test patch:
diff --git a/pkg/nonkube/api/environment_test.go b/pkg/nonkube/api/environment_test.go
index 649e36ee..01d3715a 100644
--- a/pkg/nonkube/api/environment_test.go
+++ b/pkg/nonkube/api/environment_test.go
@@ -411,8 +411,7 @@ func TestGetHostSiteHome(t *testing.T) {
 		if scenario.useXdgDataHome {
 			t.Setenv(envXdgDataHome, fakeXdgDataHome)
 		}
-		siteHome, err := GetHostSiteHome(fakeSite)
-		assert.Assert(t, err)
+		siteHome := GetHostSiteHome(fakeSite)
 		assert.Equal(t, siteHome, scenario.expectedSiteHome)
 	}
 }
diff --git a/pkg/nonkube/api/token_test.go b/pkg/nonkube/api/token_test.go
new file mode 100644
index 00000000..d00c2a54
--- /dev/null
+++ b/pkg/nonkube/api/token_test.go
@@ -0,0 +1,220 @@
+package api
+
+import (
+	"encoding/json"
+	"fmt"
+	"slices"
+	"strconv"
+	"strings"
+	"testing"
+
+	"github.com/skupperproject/skupper/pkg/apis/skupper/v1alpha1"
+	"github.com/skupperproject/skupper/pkg/certs"
+	"gotest.tools/assert"
+	v1 "k8s.io/api/core/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+)
+
+func TestCreateTokens(t *testing.T) {
+	/*
+	 * No inter-router nor edge roles in RouterAccess
+	 * RouterAccess with only one of the roles
+	 * RouterAccess with both roles
+	 * Bad user provided server certificate (no tls.crt in data)
+	 * Good user provided server certificate (no SANs)
+	 * Good user provided server certificate with valid SANs
+	   * subjectAlternativeNames (from routeraccess.spec to be ignored)
+	 * Good user provided server certificate with empty SANs
+	   * subjectAlternativeNames (from routeraccess.spec to be used)
+	*/
+
+	var clientSecret = v1.Secret{
+		ObjectMeta: metav1.ObjectMeta{
+			Name: "link-fake-router-access",
+		},
+		Data: map[string][]byte{
+			"tls.key": []byte("fake-client-key"),
+			"tls.crt": []byte("fake-client-cert"),
+			"ca.crt":  []byte("fake-client-ca"),
+		},
+	}
+
+	tests := []struct {
+		name          string
+		ra            v1alpha1.RouterAccess
+		serverSecret  v1.Secret
+		expectedHosts []string
+	}{
+		{
+			name:          "no-inter-router-or-edge-roles",
+			ra:            fakeRouterAccessNoInterRouterEdgeRoles(),
+			expectedHosts: nil,
+		},
+		{
+			name: "inter-router-role-only",
+			ra:   fakeRouterAccessInterRouterRole(),
+			expectedHosts: []string{
+				"127.0.0.1",
+			},
+		},
+		{
+			name: "both-roles",
+			ra:   fakeRouterAccessBothRoles(),
+			expectedHosts: []string{
+				"127.0.0.1",
+			},
+		},
+		{
+			name:         "invalid-server-cert",
+			ra:           fakeRouterAccessBothRoles(),
+			serverSecret: fakeServerSecretBad(),
+			expectedHosts: []string{
+				"127.0.0.1",
+			},
+		},
+		{
+			name:         "server-cert-no-hosts",
+			ra:           fakeRouterAccessBothRoles(),
+			serverSecret: fakeServerSecret([]string{}),
+			expectedHosts: []string{
+				"127.0.0.1",
+			},
+		},
+		{
+			name: "sans-provided-no-server-cert",
+			ra:   fakeRouterAccessWithSANs("fake.host.one", "fake.host.two"),
+			expectedHosts: []string{
+				"127.0.0.1",
+				"fake.host.one",
+				"fake.host.two",
+			},
+		},
+		{
+			name:         "sans-provided-empty-server-cert",
+			ra:           fakeRouterAccessWithSANs("fake.host.one", "fake.host.two"),
+			serverSecret: fakeServerSecret([]string{}),
+			expectedHosts: []string{
+				"127.0.0.1",
+				"fake.host.one",
+				"fake.host.two",
+			},
+		},
+		{
+			name:         "sans-provided-server-cert-with-hosts",
+			ra:           fakeRouterAccessWithSANs("fake.host.one", "fake.host.two"),
+			serverSecret: fakeServerSecret([]string{"server.host.one", "server.host.two"}),
+			expectedHosts: []string{
+				"127.0.0.1",
+				"server.host.one",
+				"server.host.two",
+			},
+		},
+		{
+			name:         "sans-provided-server-cert-with-hosts-and-ips",
+			ra:           fakeRouterAccessWithSANs("fake.host.one", "fake.host.two"),
+			serverSecret: fakeServerSecret([]string{"server.host.one", "server.host.two", "10.0.0.1", "10.0.0.2"}),
+			expectedHosts: []string{
+				"127.0.0.1",
+				"10.0.0.1",
+				"10.0.0.2",
+				"server.host.one",
+				"server.host.two",
+			},
+		},
+	}
+
+	for _, test := range tests {
+		t.Run(test.name, func(t *testing.T) {
+			tokens := CreateTokens(test.ra, test.serverSecret, clientSecret)
+			// validate expected token count
+			tokensJson, _ := json.MarshalIndent(tokens, "", "  ")
+			assert.Equal(t, len(tokens), len(test.expectedHosts), "expected tokens for hosts: %v - got: %v", test.expectedHosts, string(tokensJson))
+			// nothing else to validate
+			if len(test.expectedHosts) == 0 {
+				return
+			}
+			hostsFound := map[string]bool{}
+			for _, host := range test.expectedHosts {
+				hostsFound[host] = false
+			}
+			// validate tokens
+			for _, token := range tokens {
+				assert.Equal(t, token.Links[0].Name, fmt.Sprintf("link-%s", test.ra.Name))
+				assert.Equal(t, token.Links[0].Spec.Cost, 1)
+				assert.Equal(t, token.Links[0].Spec.TlsCredentials, clientSecret.Name)
+				assert.Equal(t, len(token.Links[0].Spec.Endpoints), len(test.ra.Spec.Roles))
+				var raRolesPorts = make(map[string]string)
+				for _, role := range test.ra.Spec.Roles {
+					raRolesPorts[role.Name] = strconv.Itoa(role.Port)
+				}
+				for _, endpoint := range token.Links[0].Spec.Endpoints {
+					assert.Equal(t, endpoint.Port, raRolesPorts[endpoint.Name])
+					assert.Assert(t, slices.Contains(test.expectedHosts, endpoint.Host),
+						"endpoint host %q not expected in %v", endpoint.Host, test.expectedHosts)
+					hostsFound[endpoint.Host] = true
+				}
+				assert.Equal(t, token.Secret.Name, clientSecret.Name)
+			}
+			for _, found := range hostsFound {
+				assert.Assert(t, found, "not all hosts found: %v", hostsFound)
+			}
+		})
+	}
+}
+
+func fakeRouterAccess() v1alpha1.RouterAccess {
+	var ra v1alpha1.RouterAccess
+	ra.Name = "fake-router-access"
+	return ra
+}
+
+func fakeRouterAccessNoInterRouterEdgeRoles() v1alpha1.RouterAccess {
+	var ra = fakeRouterAccess()
+	ra.Spec.Roles = []v1alpha1.RouterAccessRole{
+		{
+			Name: "normal",
+			Port: 5671,
+		},
+	}
+	return ra
+}
+
+func fakeRouterAccessInterRouterRole() v1alpha1.RouterAccess {
+	var ra = fakeRouterAccess()
+	ra.Spec.Roles = []v1alpha1.RouterAccessRole{
+		{
+			Name: "inter-router",
+			Port: 55671,
+		},
+	}
+	return ra
+}
+
+func fakeRouterAccessBothRoles() v1alpha1.RouterAccess {
+	var ra = fakeRouterAccessInterRouterRole()
+	ra.Spec.Roles = append(ra.Spec.Roles, v1alpha1.RouterAccessRole{
+		Name: "edge",
+		Port: 45671,
+	})
+	return ra
+}
+
+func fakeRouterAccessWithSANs(sans ...string) v1alpha1.RouterAccess {
+	var ra = fakeRouterAccessBothRoles()
+	ra.Spec.SubjectAlternativeNames = sans
+	return ra
+}
+
+func fakeServerSecretBad() v1.Secret {
+	ca := certs.GenerateCASecret("fake-ca", "fake-ca")
+	server := certs.GenerateSecret("fake-server-cert", "fake-server-cert", "", &ca)
+	delete(server.Data, "tls.crt")
+	return server
+}
+
+func fakeServerSecret(hosts []string) v1.Secret {
+	hostsCsv := strings.Join(hosts, ",")
+	ca := certs.GenerateCASecret("fake-ca", "fake-ca")
+	server := certs.GenerateSecret("fake-server-cert", "fake-server-cert", hostsCsv, &ca)
+	return server
+}
diff --git a/pkg/nonkube/common/fs_config_renderer_test.go b/pkg/nonkube/common/fs_config_renderer_test.go
index 3c9791c7..1c38f225 100644
--- a/pkg/nonkube/common/fs_config_renderer_test.go
+++ b/pkg/nonkube/common/fs_config_renderer_test.go
@@ -1,12 +1,14 @@
 package common
 
 import (
+	"bytes"
 	"os"
 	"path"
 	"testing"
 
 	"github.com/skupperproject/skupper/api/types"
 	"github.com/skupperproject/skupper/pkg/apis/skupper/v1alpha1"
+	"github.com/skupperproject/skupper/pkg/certs"
 	"github.com/skupperproject/skupper/pkg/nonkube/api"
 	"gotest.tools/assert"
 	corev1 "k8s.io/api/core/v1"
@@ -14,15 +16,27 @@ import (
 )
 
 func TestFileSystemConfigurationRenderer_Render(t *testing.T) {
+	testFileSystemConfigurationRendererRender(t, false)
+}
+
+func TestFileSystemConfigurationRendererWithInputCertificates_Render(t *testing.T) {
+	testFileSystemConfigurationRendererRender(t, true)
+}
+
+func testFileSystemConfigurationRendererRender(t *testing.T, addInputCertificates bool) {
 	ss := fakeSiteState()
 	ss.CreateLinkAccessesCertificates()
 	ss.CreateBridgeCertificates()
 	customOutputPath, err := os.MkdirTemp("", "fs-config-renderer-*")
 	assert.Assert(t, err)
 	defer func() {
-		err := os.RemoveAll(customOutputPath)
-		assert.Assert(t, err)
+		//err := os.RemoveAll(customOutputPath)
+		//assert.Assert(t, err)
 	}()
+	if addInputCertificates {
+		t.Logf(customOutputPath)
+		createInputCertificates(t, customOutputPath)
+	}
 	fsConfigRenderer := new(FileSystemConfigurationRenderer)
 	fsConfigRenderer.customOutputPath = customOutputPath
 	assert.Assert(t, fsConfigRenderer.Render(ss))
@@ -61,7 +75,13 @@ func TestFileSystemConfigurationRenderer_Render(t *testing.T) {
 		"certificates/link/link-one-profile/tls.key",
 		"runtime/state/platform.yaml",
 		"runtime/link/link-link-access-one-127.0.0.1.yaml",
-		"runtime/link/link-link-access-one-localhost.yaml",
+	}
+	if !addInputCertificates {
+		expectedFiles = append(expectedFiles, "runtime/link/link-link-access-one-localhost.yaml")
+	} else {
+		expectedFiles = append(expectedFiles, "runtime/link/link-link-access-one-10.0.0.1.yaml")
+		expectedFiles = append(expectedFiles, "runtime/link/link-link-access-one-10.0.0.2.yaml")
+		expectedFiles = append(expectedFiles, "runtime/link/link-link-access-one-fake.domain.yaml")
 	}
 	for _, fileName := range expectedFiles {
 		fs, err := os.Stat(path.Join(customOutputPath, fileName))
@@ -69,6 +89,61 @@ func TestFileSystemConfigurationRenderer_Render(t *testing.T) {
 		assert.Assert(t, fs.Mode().IsRegular())
 		assert.Assert(t, fs.Size() > 0)
 	}
+	if addInputCertificates {
+		compareCertificates(t, customOutputPath)
+	}
+}
+
+func compareCertificates(t *testing.T, customOutputPath string) {
+	caPath := path.Join(customOutputPath, "certificates/ca/skupper-site-ca")
+	serverPath := path.Join(customOutputPath, "certificates/server/link-access-one")
+	clientPath := path.Join(customOutputPath, "certificates/client/client-link-access-one")
+	inputCaPath := path.Join(customOutputPath, "input/certificates/ca/skupper-site-ca")
+	inputServerPath := path.Join(customOutputPath, "input/certificates/server/link-access-one")
+	inputClientPath := path.Join(customOutputPath, "input/certificates/client/client-link-access-one")
+	pathsToCompare := map[string]string{
+		caPath:     inputCaPath,
+		serverPath: inputServerPath,
+		clientPath: inputClientPath,
+	}
+	for certPath, inputCertPath := range pathsToCompare {
+		entries, err := os.ReadDir(certPath)
+		assert.Assert(t, err)
+		assert.Assert(t, len(entries) == 3)
+		for _, filename := range []string{"ca.crt", "tls.key", "tls.crt"} {
+			activeData, err := os.ReadFile(path.Join(certPath, filename))
+			assert.Assert(t, err)
+			inputData, err := os.ReadFile(path.Join(inputCertPath, filename))
+			assert.Assert(t, err)
+			assert.Assert(t, bytes.Equal(activeData, inputData))
+		}
+	}
+}
+
+func createInputCertificates(t *testing.T, customOutputPath string) {
+	// preparing certificates
+	fakeHosts := "10.0.0.1,10.0.0.2,fake.domain"
+	ca := certs.GenerateCASecret("fake-ca", "fake-ca")
+	server := certs.GenerateSecret("fake-server-cert", "fake-server-cert", fakeHosts, &ca)
+	client := certs.GenerateSecret("fake-client-cert", "fake-client-cert", "", &ca)
+
+	// paths for each provided certificate
+	caPath := path.Join(customOutputPath, "namespaces/default/input/certificates/ca/skupper-site-ca")
+	serverPath := path.Join(customOutputPath, "namespaces/default/input/certificates/server/link-access-one")
+	clientPath := path.Join(customOutputPath, "namespaces/default/input/certificates/client/client-link-access-one")
+	certsMap := map[string]corev1.Secret{
+		caPath:     ca,
+		serverPath: server,
+		clientPath: client,
+	}
+
+	// writing certificates to disk
+	for certPath, secret := range certsMap {
+		assert.Assert(t, os.MkdirAll(certPath, 0755))
+		for filename, data := range secret.Data {
+			assert.Assert(t, os.WriteFile(path.Join(certPath, filename), data, 0644))
+		}
+	}
 }
 
 func fakeSiteState() *api.SiteState {