diff --git a/Dockerfile b/Dockerfile index a751897160..ee4c29b14d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -32,6 +32,11 @@ HEALTHCHECK --start-period=30s --timeout=5s --interval=10s \ CMD curl -f http://localhost:8081/status || exit 1 RUN adduser -D -H -u 18081 nuts-usr + +# Mountable directory for additional CA certificates (*.pem, *.crt) that HTTP clients trust, on top of the OS CA bundle. +RUN mkdir -p /etc/nuts/http-trust.d && chown 18081:18081 /etc/nuts/http-trust.d +ENV NUTS_HTTPCLIENT_TLS_EXTRACERTSDIR=/etc/nuts/http-trust.d + USER 18081:18081 WORKDIR /nuts diff --git a/README.rst b/README.rst index 31d1448529..1343b9080f 100644 --- a/README.rst +++ b/README.rst @@ -186,8 +186,10 @@ The following options can be configured on the server: url Public facing URL of the server (required). Must be HTTPS when strictmode is set. verbosity info Log level (trace, debug, info, warn, error) httpclient.timeout 30s Request time-out for HTTP clients, such as '10s'. Refer to Golang's 'time.Duration' syntax for a more elaborate description of the syntax. + httpclient.tls.extracertsdir Directory containing additional CA certificates (*.pem, *.crt) that HTTP clients trust, on top of the OS CA bundle. When set, the directory must exist. **Auth** auth.authorizationendpoint.enabled false enables the v2 API's OAuth2 Authorization Endpoint, used by OpenID4VP and OpenID4VCI. This flag might be removed in a future version (or its default become 'true') as the use cases and implementation of OpenID4VP and OpenID4VCI mature. + auth.experimental.jwtbearerclient false enables the experimental RFC 7523 jwt-bearer two-VP token request flow. While disabled (the default), requests carrying a service-provider subject identifier are rejected. Subject to change without notice. **Crypto** crypto.storage Storage to use, 'fs' for file system (for development purposes), 'vaultkv' for HashiCorp Vault KV store, 'azure-keyvault' for Azure Key Vault, 'external' for an external backend (deprecated). crypto.azurekv.hsm false Whether to store the key in a hardware security module (HSM). If true, the Azure Key Vault must be configured for HSM usage. Default: false @@ -240,6 +242,7 @@ The following options can be configured on the server: tracing.servicename Service name reported to the tracing backend. Defaults to 'nuts-node'. **policy** policy.directory ./config/policy Directory to read policy files from. Policy files are JSON files that contain a scope to PresentationDefinition mapping. + policy.authzen.endpoint Base URL of the AuthZen PDP endpoint. Required when any credential profile uses scope_policy 'dynamic'; the node refuses to start if such a profile is configured but this flag is empty. ======================================== =================================================================================================================================================================================================================================================================================================================================================================================================================================================================== ============================================================================================================================================================================================================================================================================================================================================ Options specific for ``did:nuts``/gRPC diff --git a/core/server_config.go b/core/server_config.go index 434300e72b..5e87f8db65 100644 --- a/core/server_config.go +++ b/core/server_config.go @@ -86,6 +86,15 @@ type HTTPConfig struct { type HTTPClientConfig struct { // Timeout specifies the timeout for HTTP requests. Timeout time.Duration `koanf:"timeout"` + // TLS contains TLS settings for HTTP clients. + TLS HTTPClientTLSConfig `koanf:"tls"` +} + +// HTTPClientTLSConfig contains TLS settings for HTTP clients. +type HTTPClientTLSConfig struct { + // ExtraCertsDir specifies a directory with additional CA certificates (*.pem, *.crt) that HTTP clients trust, + // on top of the OS CA bundle. + ExtraCertsDir string `koanf:"extracertsdir"` } // TLSConfig specifies how TLS should be configured for connections. @@ -269,6 +278,7 @@ func FlagSet() *pflag.FlagSet { flagSet.StringSlice("didmethods", defaultCfg.DIDMethods, "Comma-separated list of enabled DID methods (without did: prefix). "+ "It also controls the order in which DIDs are returned by APIs, and which DID is used for signing if the verifying party does not impose restrictions on the DID method used.") flagSet.Duration("httpclient.timeout", defaultCfg.HTTPClient.Timeout, "Request time-out for HTTP clients, such as '10s'. Refer to Golang's 'time.Duration' syntax for a more elaborate description of the syntax.") + flagSet.String("httpclient.tls.extracertsdir", defaultCfg.HTTPClient.TLS.ExtraCertsDir, "Directory containing additional CA certificates (*.pem, *.crt) that HTTP clients trust, on top of the OS CA bundle. When set, the directory must exist.") flagSet.String("tls.certfile", defaultCfg.TLS.CertFile, "PEM file containing the certificate for the gRPC server (also used as client certificate). Required in strict mode.") flagSet.String("tls.certkeyfile", defaultCfg.TLS.CertKeyFile, "PEM file containing the private key of the gRPC server certificate. Required in strict mode.") flagSet.String("tls.truststorefile", defaultCfg.TLS.TrustStoreFile, "PEM file containing the trusted CA certificates for authenticating remote gRPC servers. Required in strict mode.") diff --git a/core/server_config_test.go b/core/server_config_test.go index ab00a13339..ec7f4c9105 100644 --- a/core/server_config_test.go +++ b/core/server_config_test.go @@ -150,6 +150,18 @@ func TestNewNutsConfig_Load(t *testing.T) { assert.Equal(t, "warn", cfg.Verbosity) }) + + t.Run("ok - httpclient.tls.extracertsdir is parsed", func(t *testing.T) { + defer reset() + os.Args = []string{"command", "--httpclient.tls.extracertsdir", "/etc/nuts/http-trust.d"} + cfg := NewServerConfig() + cmd := testCommand() + + err := cfg.Load(cmd.Flags()) + + require.NoError(t, err) + assert.Equal(t, "/etc/nuts/http-trust.d", cfg.HTTPClient.TLS.ExtraCertsDir) + }) } func TestNewNutsConfig_PrintConfig(t *testing.T) { diff --git a/discovery/module_test.go b/discovery/module_test.go index bfa83817b8..9078611bf6 100644 --- a/discovery/module_test.go +++ b/discovery/module_test.go @@ -708,7 +708,10 @@ func TestModule_GetServiceActivation(t *testing.T) { assert.Nil(t, presentation) }) t.Run("activated, no VP", func(t *testing.T) { - m, ctx := setupModule(t, storageEngine) + // disable the background refresh loop, otherwise it races with this test's ListDIDs expectation + m, ctx := setupModule(t, storageEngine, func(module *Module) { + module.config.Client.RefreshInterval = 0 + }) ctx.subjectManager.EXPECT().ListDIDs(gomock.Any(), aliceSubject).Return([]did.DID{aliceDID}, nil) next := time.Now() _ = m.store.updatePresentationRefreshTime(testServiceID, aliceSubject, nil, &next) diff --git a/docs/pages/deployment/docker.rst b/docs/pages/deployment/docker.rst index 3c853c1cdc..3fc4d281c8 100644 --- a/docs/pages/deployment/docker.rst +++ b/docs/pages/deployment/docker.rst @@ -73,6 +73,10 @@ The default working directory within the container is ``/nuts`` that provides de The container user (``18081``) has insufficient privileges by default to write to mounted directories. The required permissions can be granted by making the container user the owner of the ``data`` directory on the host. (``chown -R 18081:18081 ``) +* **/etc/nuts/http-trust.d/**: Directory with additional CA certificates (``*.pem``, ``*.crt``) that HTTP clients trust, on top of the OS CA bundle. + The image sets ``NUTS_HTTPCLIENT_TLS_EXTRACERTSDIR`` to this path by default, so mounting CA certificates here trusts them without rebuilding the image. + Files must be readable by the container user (``18081``); read-only is sufficient (``chmod -R o+r ``). + .. note:: - Nodes running the :ref:`recommended deployment ` (external storage configured for ``crypto.storage`` and ``storage.sql.connection``) that do not use did:nuts / gRPC network don't need to mount a ``data`` dir. diff --git a/docs/pages/deployment/server_options.rst b/docs/pages/deployment/server_options.rst index e6c5f3d304..d7830e855d 100755 --- a/docs/pages/deployment/server_options.rst +++ b/docs/pages/deployment/server_options.rst @@ -15,6 +15,7 @@ url Public facing URL of the server (required). Must be HTTPS when strictmode is set. verbosity info Log level (trace, debug, info, warn, error) httpclient.timeout 30s Request time-out for HTTP clients, such as '10s'. Refer to Golang's 'time.Duration' syntax for a more elaborate description of the syntax. + httpclient.tls.extracertsdir Directory containing additional CA certificates (*.pem, *.crt) that HTTP clients trust, on top of the OS CA bundle. When set, the directory must exist. **Auth** auth.authorizationendpoint.enabled false enables the v2 API's OAuth2 Authorization Endpoint, used by OpenID4VP and OpenID4VCI. This flag might be removed in a future version (or its default become 'true') as the use cases and implementation of OpenID4VP and OpenID4VCI mature. auth.experimental.jwtbearerclient false enables the experimental RFC 7523 jwt-bearer two-VP token request flow. While disabled (the default), requests carrying a service-provider subject identifier are rejected. Subject to change without notice. @@ -70,5 +71,5 @@ tracing.servicename Service name reported to the tracing backend. Defaults to 'nuts-node'. **policy** policy.directory ./config/policy Directory to read policy files from. Policy files are JSON files that contain a scope to PresentationDefinition mapping. - policy.authzen.endpoint Base URL of the AuthZen PDP endpoint. Required when any credential profile uses scope_policy 'dynamic'. + policy.authzen.endpoint Base URL of the AuthZen PDP endpoint. Required when any credential profile uses scope_policy 'dynamic'; the node refuses to start if such a profile is configured but this flag is empty. ======================================== =================================================================================================================================================================================================================================================================================================================================================================================================================================================================== ============================================================================================================================================================================================================================================================================================================================================ diff --git a/http/client/trustbundle.go b/http/client/trustbundle.go new file mode 100644 index 0000000000..3e40b8344b --- /dev/null +++ b/http/client/trustbundle.go @@ -0,0 +1,102 @@ +/* + * Copyright (C) 2026 Nuts community + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package client + +import ( + "crypto/x509" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/nuts-foundation/nuts-node/core" + "github.com/nuts-foundation/nuts-node/crypto/hash" + "github.com/nuts-foundation/nuts-node/http/log" +) + +// ConfigureTrustBundle extends the trust bundle of the shared SafeHttpTransport with additional CA certificates +// loaded from the given directory, on top of the OS CA bundle. It loads all *.pem and *.crt files and logs the +// subject and SHA-256 fingerprint of each certificate. +// If dir is empty the feature is disabled and nothing is loaded. A configured directory that can't be read +// (including a non-existent directory) or that contains an invalid certificate file results in an error. +func ConfigureTrustBundle(dir string) error { + if dir == "" { + return nil + } + + pool, err := x509.SystemCertPool() + if err != nil { + // SystemCertPool may fail on some platforms; fall back to an empty pool so the configured CAs are still trusted. + log.Logger().WithError(err).Warn("Unable to load OS CA bundle for HTTP clients, only the additional CA certificates will be trusted") + pool = x509.NewCertPool() + } + + entries, err := os.ReadDir(dir) + if err != nil { + return fmt.Errorf("unable to read HTTP client trust bundle directory (dir=%s): %w", dir, err) + } + + for _, entry := range entries { + if entry.IsDir() { + continue + } + switch strings.ToLower(filepath.Ext(entry.Name())) { + case ".pem", ".crt": + // load it + default: + continue + } + filePath := filepath.Join(dir, entry.Name()) + data, err := os.ReadFile(filePath) + if err != nil { + return fmt.Errorf("unable to read HTTP client CA certificate (file=%s): %w", filePath, err) + } + certificates, err := core.ParseCertificates(data) + if err != nil { + return fmt.Errorf("unable to parse HTTP client CA certificate (file=%s): %w", filePath, err) + } + for _, certificate := range certificates { + pool.AddCert(certificate) + log.Logger(). + WithField("file", filePath). + WithField("subject", certificate.Subject.String()). + WithField("fingerprint", hash.SHA256Sum(certificate.Raw).String()). + WithField("type", certificateKind(certificate)). + Info("Trusting additional certificate for HTTP clients") + } + } + + SafeHttpTransport.TLSClientConfig.RootCAs = pool + return nil +} + +// certificateKind classifies a certificate as a root CA, intermediate CA or (non-CA) certificate for logging. +// They all end up in the same RootCAs pool: a TLS client only has a single trust anchor pool, so every loaded +// certificate is a trust anchor (intermediates presented by the server are used for chain building during the +// handshake). The classification mirrors core.BuildTrustStore and is purely informational. +func certificateKind(certificate *x509.Certificate) string { + // Version 1 certificates lack basicConstraints.IsCA, so fall back to issuer == subject for those. + if certificate.IsCA || certificate.Version == 1 { + if certificate.Subject.String() == certificate.Issuer.String() { + return "root CA" + } + return "intermediate CA" + } + return "certificate" +} diff --git a/http/client/trustbundle_test.go b/http/client/trustbundle_test.go new file mode 100644 index 0000000000..1fbe586c2d --- /dev/null +++ b/http/client/trustbundle_test.go @@ -0,0 +1,235 @@ +/* + * Copyright (C) 2026 Nuts community + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package client + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "math/big" + "net" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestConfigureTrustBundle(t *testing.T) { + // ConfigureTrustBundle mutates the global SafeHttpTransport, so reset it after each subtest. + resetTransport := func(t *testing.T) { + original := SafeHttpTransport.TLSClientConfig.RootCAs + t.Cleanup(func() { + SafeHttpTransport.TLSClientConfig.RootCAs = original + }) + SafeHttpTransport.TLSClientConfig.RootCAs = nil + } + + t.Run("empty directory argument is a no-op", func(t *testing.T) { + resetTransport(t) + + err := ConfigureTrustBundle("") + + require.NoError(t, err) + assert.Nil(t, SafeHttpTransport.TLSClientConfig.RootCAs) + }) + t.Run("non-existent directory returns an error", func(t *testing.T) { + resetTransport(t) + + err := ConfigureTrustBundle(filepath.Join(t.TempDir(), "does-not-exist")) + + require.Error(t, err) + assert.Nil(t, SafeHttpTransport.TLSClientConfig.RootCAs) + }) + t.Run("loads .pem and .crt files", func(t *testing.T) { + resetTransport(t) + dir := t.TempDir() + caCert, caKey := newTestCA(t) + writePEM(t, filepath.Join(dir, "ca.pem"), caCert.Raw) + otherCA, _ := newTestCA(t) + writePEM(t, filepath.Join(dir, "other.crt"), otherCA.Raw) + // files with other extensions are ignored + require.NoError(t, os.WriteFile(filepath.Join(dir, "notes.txt"), []byte("not a certificate"), 0644)) + + err := ConfigureTrustBundle(dir) + + require.NoError(t, err) + pool := SafeHttpTransport.TLSClientConfig.RootCAs + require.NotNil(t, pool) + // A leaf signed by the loaded CA must verify against the configured pool, proving the CA was added. + leaf := newTestLeaf(t, caCert, caKey) + _, err = leaf.Verify(x509.VerifyOptions{Roots: pool}) + assert.NoError(t, err) + }) + t.Run("invalid certificate file returns an error", func(t *testing.T) { + resetTransport(t) + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "broken.pem"), []byte("-----BEGIN CERTIFICATE-----\nnot base64\n-----END CERTIFICATE-----"), 0644)) + + err := ConfigureTrustBundle(dir) + + assert.Error(t, err) + }) +} + +func TestCertificateKind(t *testing.T) { + rootCA, rootKey := newTestCA(t) + + t.Run("root CA", func(t *testing.T) { + assert.Equal(t, "root CA", certificateKind(rootCA)) + }) + t.Run("intermediate CA", func(t *testing.T) { + assert.Equal(t, "intermediate CA", certificateKind(newTestIntermediate(t, rootCA, rootKey))) + }) + t.Run("non-CA certificate", func(t *testing.T) { + assert.Equal(t, "certificate", certificateKind(newTestLeaf(t, rootCA, rootKey))) + }) +} + +// TestConfigureTrustBundle_endToEnd verifies that a client backed by SafeHttpTransport can only reach an HTTPS server +// whose certificate is signed by a custom CA after that CA is loaded via ConfigureTrustBundle. +func TestConfigureTrustBundle_endToEnd(t *testing.T) { + original := SafeHttpTransport.TLSClientConfig.RootCAs + t.Cleanup(func() { SafeHttpTransport.TLSClientConfig.RootCAs = original }) + SafeHttpTransport.TLSClientConfig.RootCAs = nil + + caCert, caKey := newTestCA(t) + server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) + })) + server.TLS = &tls.Config{Certificates: []tls.Certificate{newTestServerCert(t, caCert, caKey)}} + server.StartTLS() + defer server.Close() + + // Before loading the CA, the server's certificate is signed by an unknown authority. + _, err := New(time.Second).Do(mustGet(t, server.URL)) + require.Error(t, err) + // Exact wording is platform-dependent (Go's verifier vs. the OS verifier), so match the common prefix. + assert.Contains(t, err.Error(), "failed to verify certificate") + + // Load the CA into the trust bundle. + dir := t.TempDir() + writePEM(t, filepath.Join(dir, "ca.pem"), caCert.Raw) + require.NoError(t, ConfigureTrustBundle(dir)) + + // Now the server is trusted. + response, err := New(time.Second).Do(mustGet(t, server.URL)) + require.NoError(t, err) + assert.Equal(t, http.StatusNoContent, response.StatusCode) +} + +func mustGet(t *testing.T, url string) *http.Request { + t.Helper() + request, err := http.NewRequest(http.MethodGet, url, nil) + require.NoError(t, err) + return request +} + +// newTestServerCert creates a TLS server certificate for 127.0.0.1, signed by the given CA. +func newTestServerCert(t *testing.T, ca *x509.Certificate, caKey *ecdsa.PrivateKey) tls.Certificate { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + template := &x509.Certificate{ + SerialNumber: big.NewInt(3), + Subject: pkix.Name{CommonName: "127.0.0.1"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + IPAddresses: []net.IP{net.ParseIP("127.0.0.1"), net.ParseIP("::1")}, + } + der, err := x509.CreateCertificate(rand.Reader, template, ca, &key.PublicKey, caKey) + require.NoError(t, err) + return tls.Certificate{Certificate: [][]byte{der}, PrivateKey: key} +} + +// newTestCA creates a self-signed CA certificate for use in tests. +func newTestCA(t *testing.T) (*x509.Certificate, *ecdsa.PrivateKey) { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + template := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "Test CA"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour), + IsCA: true, + KeyUsage: x509.KeyUsageCertSign, + BasicConstraintsValid: true, + } + der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) + require.NoError(t, err) + cert, err := x509.ParseCertificate(der) + require.NoError(t, err) + return cert, key +} + +// newTestIntermediate creates an intermediate CA certificate signed by the given parent CA. +func newTestIntermediate(t *testing.T, parent *x509.Certificate, parentKey *ecdsa.PrivateKey) *x509.Certificate { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + template := &x509.Certificate{ + SerialNumber: big.NewInt(4), + Subject: pkix.Name{CommonName: "Test Intermediate CA"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour), + IsCA: true, + KeyUsage: x509.KeyUsageCertSign, + BasicConstraintsValid: true, + } + der, err := x509.CreateCertificate(rand.Reader, template, parent, &key.PublicKey, parentKey) + require.NoError(t, err) + cert, err := x509.ParseCertificate(der) + require.NoError(t, err) + return cert +} + +// newTestLeaf creates a leaf certificate signed by the given CA. +func newTestLeaf(t *testing.T, ca *x509.Certificate, caKey *ecdsa.PrivateKey) *x509.Certificate { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + template := &x509.Certificate{ + SerialNumber: big.NewInt(2), + Subject: pkix.Name{CommonName: "leaf.example.com"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour), + } + der, err := x509.CreateCertificate(rand.Reader, template, ca, &key.PublicKey, caKey) + require.NoError(t, err) + cert, err := x509.ParseCertificate(der) + require.NoError(t, err) + return cert +} + +func writePEM(t *testing.T, path string, der []byte) { + t.Helper() + data := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}) + require.NoError(t, os.WriteFile(path, data, 0644)) +} diff --git a/http/engine.go b/http/engine.go index d2c1533056..a09d95966f 100644 --- a/http/engine.go +++ b/http/engine.go @@ -66,7 +66,9 @@ func (h Engine) Router() core.EchoRouter { // Configure loads the configuration for the HTTP engine. func (h *Engine) Configure(serverConfig core.ServerConfig) error { - h.configureClient(serverConfig) + if err := h.configureClient(serverConfig); err != nil { + return err + } // We have 2 HTTP interfaces: internal and public // The following paths (and their subpaths) are bound to the internal interface: @@ -98,12 +100,17 @@ func (h *Engine) Configure(serverConfig core.ServerConfig) error { return h.applyAuthMiddleware(h.server, InternalPath, h.config.Internal.Auth) } -func (h *Engine) configureClient(serverConfig core.ServerConfig) { +func (h *Engine) configureClient(serverConfig core.ServerConfig) error { client.StrictMode = serverConfig.Strictmode + // Extend the HTTP client trust bundle with additional CA certificates, if configured. + if err := client.ConfigureTrustBundle(serverConfig.HTTPClient.TLS.ExtraCertsDir); err != nil { + return err + } // Configure the HTTP caching client, if enabled. Set it to http.DefaultTransport so it can be used by any subsystem. if h.config.ResponseCacheSize > 0 { client.DefaultCachingTransport = client.NewCachingTransport(client.SafeHttpTransport, h.config.ResponseCacheSize) } + return nil } func (h *Engine) applyTracingMiddleware(echoServer core.EchoRouter) {