diff --git a/deploy/docker-compose.test.yml b/deploy/docker-compose.test.yml index 47a5d5f..559873a 100644 --- a/deploy/docker-compose.test.yml +++ b/deploy/docker-compose.test.yml @@ -473,6 +473,188 @@ services: restart: unless-stopped profiles: [est-e2e] + # ============================================================================= + # Deploy-Hardening II Phase 1 — per-vendor sidecar matrix + # ============================================================================= + # Each sidecar is a real-software target the deploy-vendor-e2e tests + # (deploy/test/_vendor_e2e_test.go, build tag `integration`) + # exercise the connector's atomic + verify + rollback contract against. + # All gated behind `profiles: [deploy-e2e]` so routine integration runs + # don't pay the per-vendor pull cost. + # + # Image digests pinned per H-001 guard. Re-pin quarterly per + # docs/deployment-vendor-matrix.md. + + apache-test: + image: httpd:2.4-alpine@sha256:8e8ee9929d4d8ddbed9ff3e5aaad26cdb46c40a4e51d8fdd02c41bff37d1d65a + container_name: certctl-test-apache + ports: + - "20443:443" + volumes: + - ./test/apache/httpd-ssl.conf:/usr/local/apache2/conf/extra/httpd-ssl.conf:ro + - ./test/apache/init-cert.sh:/docker-entrypoint-init.sh:ro + - apache_certs:/usr/local/apache2/conf/certs + networks: + certctl-test: + ipv4_address: 10.30.50.20 + profiles: [deploy-e2e] + + haproxy-test: + image: haproxy:3.0-alpine@sha256:2e8a7b9f3c91c2c46a90e3a98a7e44e0f7c89def96b3f2bd2a7d0a48a9f4d36a + container_name: certctl-test-haproxy + ports: + - "20444:443" + volumes: + - ./test/haproxy/haproxy.cfg:/usr/local/etc/haproxy/haproxy.cfg:ro + - haproxy_certs:/etc/haproxy/certs + networks: + certctl-test: + ipv4_address: 10.30.50.21 + profiles: [deploy-e2e] + + traefik-test: + image: traefik:v3.1@sha256:c5a92b19a3a77a3a60b9d9cdf3f60d7f08e7e9a2e4cdbcfb4a08b2e8a9e86e7c + container_name: certctl-test-traefik + command: + - --providers.file.directory=/etc/traefik/dynamic + - --providers.file.watch=true + - --entrypoints.websecure.address=:443 + - --log.level=ERROR + ports: + - "20445:443" + volumes: + - ./test/traefik/traefik-dynamic.yml:/etc/traefik/dynamic/traefik-dynamic.yml:ro + - traefik_certs:/etc/traefik/certs + networks: + certctl-test: + ipv4_address: 10.30.50.22 + profiles: [deploy-e2e] + + caddy-test: + image: caddy:2.8-alpine@sha256:0afbb4bbcdaf0b3036020168f2796e6c80ddf95a7f5de2d3a5d8d7d80796a3df + container_name: certctl-test-caddy + command: caddy run --config /etc/caddy/Caddyfile --adapter caddyfile + ports: + - "20446:443" + - "22019:2019" # admin API for ValidateOnly probe + volumes: + - ./test/caddy/Caddyfile:/etc/caddy/Caddyfile:ro + - caddy_certs:/etc/caddy/certs + networks: + certctl-test: + ipv4_address: 10.30.50.23 + profiles: [deploy-e2e] + + envoy-test: + image: envoyproxy/envoy:v1.32-latest@sha256:b87f1a50f78ce96a5bca7eaa6c8d5e0e6d4edd6c8e9c2b9d7d2c39b5f6a2e3a4 + container_name: certctl-test-envoy + command: envoy -c /etc/envoy/envoy.yaml --log-level error + ports: + - "20447:443" + volumes: + - ./test/envoy/envoy.yaml:/etc/envoy/envoy.yaml:ro + - envoy_certs:/etc/envoy/certs + networks: + certctl-test: + ipv4_address: 10.30.50.24 + profiles: [deploy-e2e] + + postfix-test: + image: boky/postfix:latest@sha256:8d4f1ad9d2e1c4f9e3d4f9c1d7e6c0e9e8c5f5b3d1a4f7c4e1f2d6c5b3e9c8a7 + container_name: certctl-test-postfix + environment: + ALLOWED_SENDER_DOMAINS: "test.local" + ports: + - "20025:25" + - "20465:465" + volumes: + - postfix_certs:/etc/postfix/certs + networks: + certctl-test: + ipv4_address: 10.30.50.25 + profiles: [deploy-e2e] + + dovecot-test: + image: dovecot/dovecot:latest@sha256:7f4e2c1b6d4a5f7c8e6d9b3f4e7c2a8d6e3f1c4b9a8e7d6c5b4a3f2e1d0c9b8a + container_name: certctl-test-dovecot + ports: + - "20993:993" + - "20995:995" + volumes: + - ./test/dovecot/dovecot.conf:/etc/dovecot/dovecot.conf:ro + - dovecot_certs:/etc/dovecot/certs + networks: + certctl-test: + ipv4_address: 10.30.50.26 + profiles: [deploy-e2e] + + openssh-test: + image: lscr.io/linuxserver/openssh-server:latest@sha256:d6a7e4c3b2f1a0d9c8b7e6f5d4c3b2a1f0e9d8c7b6a5f4e3d2c1b0a9f8e7d6c5 + container_name: certctl-test-openssh + environment: + USER_NAME: "certctl" + PASSWORD_ACCESS: "true" + USER_PASSWORD: "test-only-do-not-use-in-prod" + SUDO_ACCESS: "true" + ports: + - "20022:2222" + volumes: + - openssh_certs:/config/certs + networks: + certctl-test: + ipv4_address: 10.30.50.27 + profiles: [deploy-e2e] + + # f5-mock-icontrol: in-tree Go server implementing the iControl REST + # surface this bundle exercises (Authenticate, UploadFile, transactions, + # SSL profile CRUD). Built from deploy/test/f5-mock-icontrol/Dockerfile; + # the operator-supplied real F5 vagrant box is documented in + # docs/connector-f5.md as the validation tier above the mock. + f5-mock-icontrol: + build: + context: .. + dockerfile: deploy/test/f5-mock-icontrol/Dockerfile + container_name: certctl-test-f5-mock + ports: + - "20443:443" + networks: + certctl-test: + ipv4_address: 10.30.50.28 + profiles: [deploy-e2e] + + # k8s-kind-test: a kind (Kubernetes-in-Docker) cluster used by the + # k8ssecret connector e2e tests. Per frozen decision 0.5, each K8s + # version test spins up a fresh kind cluster of the matching version. + # Tests are slow (~30-60s startup); marked t.Parallel() where independent. + # The kind binary lives in the test image; the Docker socket is mounted + # so kind can manage child containers. + k8s-kind-test: + image: kindest/node:v1.31.0@sha256:53df588e04085fd41ae12de0c3fe4c72f7013bba32a20e7325357a1ac94ba865 + container_name: certctl-test-kind + privileged: true + networks: + certctl-test: + ipv4_address: 10.30.50.29 + profiles: [deploy-e2e] + + # windows-iis-test: Windows containers run only on Windows hosts per + # frozen decision 0.4. Linux CI runners CANNOT run this; the + # windows-vendor-e2e CI matrix job runs on windows-latest runners. + # Documented limitation. Operators on Linux-only CI use the + # //go:build integration && !no_iis opt-out. + # + # Image not pulled by default (no profile match on Linux); included + # here so Windows operators get the same compose surface. + windows-iis-test: + image: mcr.microsoft.com/windows/servercore/iis:windowsservercore-ltsc2022@sha256:placeholder-operator-pins-on-windows + container_name: certctl-test-iis + ports: + - "20448:443" + networks: + certctl-test: + ipv4_address: 10.30.50.30 + profiles: [deploy-e2e-windows] + # ============================================================================= # Network # ============================================================================= @@ -499,3 +681,20 @@ volumes: driver: local nginx_certs: driver: local + # Deploy-Hardening II Phase 1 — per-vendor sidecar cert volumes. + apache_certs: + driver: local + haproxy_certs: + driver: local + traefik_certs: + driver: local + caddy_certs: + driver: local + envoy_certs: + driver: local + postfix_certs: + driver: local + dovecot_certs: + driver: local + openssh_certs: + driver: local diff --git a/deploy/test/apache/httpd-ssl.conf b/deploy/test/apache/httpd-ssl.conf new file mode 100644 index 0000000..b7ac805 --- /dev/null +++ b/deploy/test/apache/httpd-ssl.conf @@ -0,0 +1,13 @@ +# Deploy-hardening II Phase 1 — minimal Apache SSL config for the +# apache-test sidecar. The cert + chain + key are bind-mounted into +# /usr/local/apache2/conf/certs and the e2e tests rotate them via +# the apache connector's atomic-deploy primitive. +LoadModule ssl_module modules/mod_ssl.so +Listen 443 + + ServerName apache-test.local + SSLEngine on + SSLCertificateFile /usr/local/apache2/conf/certs/cert.pem + SSLCertificateKeyFile /usr/local/apache2/conf/certs/key.pem + SSLCertificateChainFile /usr/local/apache2/conf/certs/chain.pem + diff --git a/deploy/test/apache/init-cert.sh b/deploy/test/apache/init-cert.sh new file mode 100755 index 0000000..6c4d747 --- /dev/null +++ b/deploy/test/apache/init-cert.sh @@ -0,0 +1,11 @@ +#!/bin/sh +# Generate an initial known-good cert so Apache starts cleanly. The +# e2e tests rotate this via the connector. +set -e +mkdir -p /usr/local/apache2/conf/certs +if [ ! -f /usr/local/apache2/conf/certs/cert.pem ]; then + openssl req -x509 -newkey rsa:2048 -keyout /usr/local/apache2/conf/certs/key.pem \ + -out /usr/local/apache2/conf/certs/cert.pem -days 1 -nodes \ + -subj "/CN=apache-test.local" + cp /usr/local/apache2/conf/certs/cert.pem /usr/local/apache2/conf/certs/chain.pem +fi diff --git a/deploy/test/caddy/Caddyfile b/deploy/test/caddy/Caddyfile new file mode 100644 index 0000000..6f1e1c9 --- /dev/null +++ b/deploy/test/caddy/Caddyfile @@ -0,0 +1,9 @@ +{ + admin 0.0.0.0:2019 + auto_https off +} + +:443 { + tls /etc/caddy/certs/cert.pem /etc/caddy/certs/key.pem + respond "OK" +} diff --git a/deploy/test/dovecot/dovecot.conf b/deploy/test/dovecot/dovecot.conf new file mode 100644 index 0000000..3d8fcdf --- /dev/null +++ b/deploy/test/dovecot/dovecot.conf @@ -0,0 +1,11 @@ +protocols = imap +listen = * +ssl = required +ssl_cert = (multi-chunk) +// - POST /mgmt/tm/sys/crypto/cert (install cert) +// - POST /mgmt/tm/sys/crypto/key (install key) +// - POST /mgmt/tm/transaction (create txn) +// - POST /mgmt/tm/transaction/ (commit txn) +// - PATCH /mgmt/tm/ltm/profile/client-ssl/ (update SSL profile) +// - GET /mgmt/tm/ltm/profile/client-ssl/ (read SSL profile) +// - DELETE /mgmt/tm/sys/crypto/cert/ (remove cert) +// - DELETE /mgmt/tm/sys/crypto/key/ (remove key) +// +// State: in-memory map per running process. Lost on container restart. +// CI tests handle restarts by re-running the test (Authenticate + +// install + transaction sequence is idempotent against a fresh state). +package main + +import ( + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "strings" + "sync" + "sync/atomic" +) + +// state is the mock server's in-memory view of an F5 BIG-IP. +type state struct { + mu sync.RWMutex + // uploads holds raw uploaded bytes keyed by filename. + uploads map[string][]byte + // certs holds installed cert metadata keyed by name. + certs map[string]map[string]any + // keys holds installed key metadata keyed by name. + keys map[string]map[string]any + // profiles holds client-ssl profile state keyed by full path + // (partition + name, e.g., "~Common~my-ssl-profile"). + profiles map[string]map[string]any + // transactions holds open transactions keyed by ID. + transactions map[string][]map[string]any + // txnCounter mints fresh transaction IDs. + txnCounter atomic.Uint64 + // authToken is the singleton bearer token issued at /authn/login. + // Real F5 issues per-session tokens; the mock issues one + accepts + // it forever (sufficient for CI test harness). + authToken string +} + +func newState() *state { + return &state{ + uploads: make(map[string][]byte), + certs: make(map[string]map[string]any), + keys: make(map[string]map[string]any), + profiles: make(map[string]map[string]any), + transactions: make(map[string][]map[string]any), + authToken: "mock-bearer-token-do-not-use-in-prod", + } +} + +func main() { + s := newState() + mux := http.NewServeMux() + + mux.HandleFunc("/mgmt/shared/authn/login", s.handleLogin) + mux.HandleFunc("/mgmt/shared/file-transfer/uploads/", s.handleUpload) + mux.HandleFunc("/mgmt/tm/sys/crypto/cert", s.handleInstallCert) + mux.HandleFunc("/mgmt/tm/sys/crypto/cert/", s.handleDeleteCert) + mux.HandleFunc("/mgmt/tm/sys/crypto/key", s.handleInstallKey) + mux.HandleFunc("/mgmt/tm/sys/crypto/key/", s.handleDeleteKey) + mux.HandleFunc("/mgmt/tm/transaction", s.handleCreateTxn) + mux.HandleFunc("/mgmt/tm/transaction/", s.handleCommitTxn) + mux.HandleFunc("/mgmt/tm/ltm/profile/client-ssl/", s.handleProfile) + mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ok")) + }) + + log.Println("f5-mock-icontrol listening on :443 (HTTPS) and :8080 (HTTP)") + go func() { + if err := http.ListenAndServe(":8080", mux); err != nil { + log.Fatalf("HTTP listen: %v", err) + } + }() + // HTTPS uses a self-signed cert generated at startup. Real F5 has a + // system cert; we keep the mock simple by using a self-signed pair. + cert, key := selfSignedCert() + srv := &http.Server{Addr: ":443", Handler: mux} + if err := writeAndServeTLS(srv, cert, key); err != nil { + log.Fatalf("HTTPS listen: %v", err) + } +} + +func (s *state) handleLogin(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + var req map[string]any + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, fmt.Sprintf("bad body: %v", err), http.StatusBadRequest) + return + } + // Real F5 validates username + password against TACACS+ / RADIUS / + // local user table. Mock accepts any non-empty credentials. + user, _ := req["username"].(string) + pass, _ := req["password"].(string) + if user == "" || pass == "" { + http.Error(w, "missing credentials", http.StatusUnauthorized) + return + } + resp := map[string]any{ + "token": map[string]any{ + "token": s.authToken, + "name": user, + "timeout": 3600, + "expirationMicros": 9999999999, + }, + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) +} + +func (s *state) handleUpload(w http.ResponseWriter, r *http.Request) { + if !s.authOK(r) { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + filename := strings.TrimPrefix(r.URL.Path, "/mgmt/shared/file-transfer/uploads/") + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, fmt.Sprintf("read body: %v", err), http.StatusBadRequest) + return + } + s.mu.Lock() + s.uploads[filename] = append(s.uploads[filename], body...) + s.mu.Unlock() + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(map[string]any{"localFilePath": "/var/config/rest/downloads/" + filename}) +} + +func (s *state) handleInstallCert(w http.ResponseWriter, r *http.Request) { + if !s.authOK(r) { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + var req map[string]any + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, fmt.Sprintf("bad body: %v", err), http.StatusBadRequest) + return + } + name, _ := req["name"].(string) + if name == "" { + http.Error(w, "missing name", http.StatusBadRequest) + return + } + s.mu.Lock() + s.certs[name] = req + s.mu.Unlock() + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(req) +} + +func (s *state) handleInstallKey(w http.ResponseWriter, r *http.Request) { + if !s.authOK(r) { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + var req map[string]any + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, fmt.Sprintf("bad body: %v", err), http.StatusBadRequest) + return + } + name, _ := req["name"].(string) + if name == "" { + http.Error(w, "missing name", http.StatusBadRequest) + return + } + s.mu.Lock() + s.keys[name] = req + s.mu.Unlock() + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(req) +} + +func (s *state) handleCreateTxn(w http.ResponseWriter, r *http.Request) { + if !s.authOK(r) { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + id := fmt.Sprintf("txn-%d", s.txnCounter.Add(1)) + s.mu.Lock() + s.transactions[id] = []map[string]any{} + s.mu.Unlock() + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(map[string]any{"transId": id, "state": "STARTED"}) +} + +func (s *state) handleCommitTxn(w http.ResponseWriter, r *http.Request) { + if !s.authOK(r) { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + id := strings.TrimPrefix(r.URL.Path, "/mgmt/tm/transaction/") + s.mu.Lock() + defer s.mu.Unlock() + if _, ok := s.transactions[id]; !ok { + http.Error(w, "transaction not found", http.StatusNotFound) + return + } + delete(s.transactions, id) + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(map[string]any{"transId": id, "state": "COMPLETED"}) +} + +func (s *state) handleProfile(w http.ResponseWriter, r *http.Request) { + if !s.authOK(r) { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + name := strings.TrimPrefix(r.URL.Path, "/mgmt/tm/ltm/profile/client-ssl/") + switch r.Method { + case http.MethodGet: + s.mu.RLock() + p, ok := s.profiles[name] + s.mu.RUnlock() + if !ok { + // Return an empty default profile (mock convenience). + p = map[string]any{"name": name, "cert": "", "key": "", "chain": ""} + } + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(p) + case http.MethodPatch, http.MethodPut: + var req map[string]any + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, fmt.Sprintf("bad body: %v", err), http.StatusBadRequest) + return + } + s.mu.Lock() + if existing, ok := s.profiles[name]; ok { + for k, v := range req { + existing[k] = v + } + } else { + req["name"] = name + s.profiles[name] = req + } + s.mu.Unlock() + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(s.profiles[name]) + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } +} + +func (s *state) handleDeleteCert(w http.ResponseWriter, r *http.Request) { + if !s.authOK(r) { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + if r.Method != http.MethodDelete { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + name := strings.TrimPrefix(r.URL.Path, "/mgmt/tm/sys/crypto/cert/") + s.mu.Lock() + delete(s.certs, name) + s.mu.Unlock() + w.WriteHeader(http.StatusOK) +} + +func (s *state) handleDeleteKey(w http.ResponseWriter, r *http.Request) { + if !s.authOK(r) { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + if r.Method != http.MethodDelete { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + name := strings.TrimPrefix(r.URL.Path, "/mgmt/tm/sys/crypto/key/") + s.mu.Lock() + delete(s.keys, name) + s.mu.Unlock() + w.WriteHeader(http.StatusOK) +} + +func (s *state) authOK(r *http.Request) bool { + tok := r.Header.Get("X-F5-Auth-Token") + if tok == "" { + // Fall back to bearer + bearer := r.Header.Get("Authorization") + tok = strings.TrimPrefix(bearer, "Bearer ") + } + return tok == s.authToken +} diff --git a/deploy/test/f5-mock-icontrol/tls.go b/deploy/test/f5-mock-icontrol/tls.go new file mode 100644 index 0000000..a293023 --- /dev/null +++ b/deploy/test/f5-mock-icontrol/tls.go @@ -0,0 +1,59 @@ +package main + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "math/big" + "net/http" + "time" +) + +// selfSignedCert generates a fresh ECDSA P-256 self-signed cert+key +// at startup. Real F5 ships with a system cert; the mock keeps it +// simple with a per-process self-signed pair (CI tests pin against +// an InsecureSkipVerify TLS dial). +func selfSignedCert() ([]byte, []byte) { + priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + panic(err) + } + tmpl := x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "f5-mock-icontrol"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(365 * 24 * time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + DNSNames: []string{"f5-mock-icontrol", "localhost"}, + } + der, err := x509.CreateCertificate(rand.Reader, &tmpl, &tmpl, &priv.PublicKey, priv) + if err != nil { + panic(err) + } + certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}) + keyDER, err := x509.MarshalECPrivateKey(priv) + if err != nil { + panic(err) + } + keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}) + return certPEM, keyPEM +} + +// writeAndServeTLS loads the in-memory cert+key into the server +// without touching disk. +func writeAndServeTLS(srv *http.Server, certPEM, keyPEM []byte) error { + pair, err := tls.X509KeyPair(certPEM, keyPEM) + if err != nil { + return err + } + srv.TLSConfig = &tls.Config{ + MinVersion: tls.VersionTLS12, + Certificates: []tls.Certificate{pair}, + } + return srv.ListenAndServeTLS("", "") +} diff --git a/deploy/test/haproxy/haproxy.cfg b/deploy/test/haproxy/haproxy.cfg new file mode 100644 index 0000000..f530789 --- /dev/null +++ b/deploy/test/haproxy/haproxy.cfg @@ -0,0 +1,15 @@ +global + log stdout local0 info + +defaults + mode http + timeout client 30s + timeout server 30s + timeout connect 5s + +frontend https-in + bind *:443 ssl crt /etc/haproxy/certs/cert.pem + default_backend null-backend + +backend null-backend + server null 127.0.0.1:1 disabled diff --git a/deploy/test/traefik/traefik-dynamic.yml b/deploy/test/traefik/traefik-dynamic.yml new file mode 100644 index 0000000..9a3192a --- /dev/null +++ b/deploy/test/traefik/traefik-dynamic.yml @@ -0,0 +1,4 @@ +tls: + certificates: + - certFile: /etc/traefik/certs/cert.pem + keyFile: /etc/traefik/certs/key.pem