diff --git a/pkg/clouds/pulumi/vm/compose_stack.go b/pkg/clouds/pulumi/vm/compose_stack.go index bf5dc85b..cf829c7d 100644 --- a/pkg/clouds/pulumi/vm/compose_stack.go +++ b/pkg/clouds/pulumi/vm/compose_stack.go @@ -142,6 +142,33 @@ func VmComposeStack(ctx *sdk.Context, stack api.Stack, input api.ResourceInput, // run at once, and what the stack's URL is. servesDomain := vm.ServesDomain(in, parentVm.IngressEnabled) + // The scheme the machine answers on. Caddy either terminates TLS or serves plain + // HTTP for an edge that does (`ingress.originTls: false` on the vm resource) — and + // every log line and export below has to say which, because "http" in a URL an + // operator pastes into a browser is the only visible difference between a working + // no-origin-TLS fleet and a broken HTTPS one. + originScheme := lo.If(parentVm.NoOriginTls, "http").Else("https") + + // `domainProxied: true` asks for a Cloudflare-proxied record, and the proxy + // terminates TLS at the edge. That breaks the HTTP-01 challenge Caddy runs for + // itself — so it is legal only on a machine that runs no ACME. + // + // Refused here and not in validateStackConfig (clouds/vm/convert.go, which still + // carries the note) because the answer lives in the PARENT stack's resource and the + // convert tier holds no parent reference. The cost is a later refusal; the benefit + // is that the arrangement is reachable at all. + if lo.FromPtr(stackConfig.DomainProxied) && !parentVm.NoOriginTls { + return nil, errors.Errorf( + "stack %q sets `domainProxied: true`, but vm %q terminates TLS itself: Caddy obtains the "+ + "certificate over HTTP-01, and a proxied domain answers that challenge at Cloudflare's edge "+ + "instead of on the machine, so no certificate ever issues. Either set `ingress.originTls: "+ + "false` on the `type: %s` resource in the parent stack and re-provision it — the machine then "+ + "serves plain HTTP for Cloudflare to terminate in front — or drop `domainProxied` and let the "+ + "machine serve HTTPS directly", + stackName, in.VmResource, vm.ResourceTypeVm, + ) + } + // The ingress host port is published only when Caddy is NOT in front. That is not // a tidiness choice: a published port is a singleton, and it is the one thing that // prevents both slots from running at once (see stopOtherFirst below). Setting it @@ -153,9 +180,9 @@ func VmComposeStack(ctx *sdk.Context, stack api.Stack, input api.ResourceInput, "stack %q declares no ingress service, so nothing is reachable from outside the machine", stackName) case servesDomain: params.Log.Info(ctx.Context(), - "vm %q serves %q of stack %q on https://%s — no host port is published, and the deploy flips slots "+ + "vm %q serves %q of stack %q on %s://%s — no host port is published, and the deploy flips slots "+ "with a Caddy reload", - in.VmResource, in.Deployment.IngressContainer.Name, stackName, stackConfig.Domain) + in.VmResource, in.Deployment.IngressContainer.Name, stackName, originScheme, stackConfig.Domain) default: hostPort = vm.IngressHostPort(stackName, env) params.Log.Info(ctx.Context(), "publishing ingress service %q of stack %q on host port %d", @@ -284,8 +311,9 @@ func VmComposeStack(ctx *sdk.Context, stack api.Stack, input api.ResourceInput, ctx: ctx, stack: stack, input: input, params: params, in: in, stackName: stackName, env: env, version: version, stackConfig: stackConfig, plan: plan, hostPort: hostPort, servesDomain: servesDomain, - recreate: in.EffectiveStrategy() == vm.StrategyRecreate, - envHash: envHash, envContent: envContent, + noOriginTls: parentVm.NoOriginTls, + recreate: in.EffectiveStrategy() == vm.StrategyRecreate, + envHash: envHash, envContent: envContent, } // Rolling, not fanned out. The machines are independent and Pulumi would happily @@ -311,11 +339,14 @@ func VmComposeStack(ctx *sdk.Context, stack api.Stack, input api.ResourceInput, // on a host port and an A record at `domain` would resolve to a machine with // nothing listening on 443. The warnings above have already said why in that case. // - // `DomainProxied` is nil on this path today — validateStackConfig refuses it - // (clouds/vm/convert.go) because a proxied domain breaks the ACME challenge Caddy - // runs itself — so this reads false. It is read rather than hardcoded because the - // refusal is due to become conditional on the fleet declaring no origin TLS, and a - // hardcoded false would then be a silently ignored setting. + // `DomainProxied` is read rather than hardcoded because it is now reachable. The + // refusal moved into this function (above) and allows it on a fleet that declares + // `ingress.originTls: false`, where proxying is not merely permitted but the entire + // front door: Cloudflare terminates TLS and the machines answer plain HTTP, so an + // unproxied record would hand clients a cleartext origin. On a fleet that terminates + // its own TLS the refusal has already returned by here, so this still reads false + // there — a proxied domain answers the HTTP-01 challenge at the edge instead of on + // the machine and no certificate ever issues. if servesDomain && stackConfig.Domain != "" { if err := fleetDnsRecords(ctx, params, stackName, stackConfig.Domain, parentVm.Addresses, lo.FromPtr(stackConfig.DomainProxied)); err != nil { @@ -364,9 +395,13 @@ type composeDeploy struct { plan *imagePlan hostPort int servesDomain bool - recreate bool - envHash string - envContent string + // noOriginTls is parentVmFacts.NoOriginTls, carried down because the site file and + // the exported URL are both written per host and both have to name the scheme the + // RESOURCE decided. + noOriginTls bool + recreate bool + envHash string + envContent string } // deployComposeToHost runs the whole deploy against ONE machine of the fleet. @@ -622,10 +657,11 @@ func deployComposeToHost( if servesDomain { routeOut := slotOut.ApplyT(func(slot string) (string, error) { site, err := vm.RenderCaddySite(in, vm.RenderOptions{ - Stack: stackName, - Env: env, - Slot: slot, - Version: version, + Stack: stackName, + Env: env, + Slot: slot, + Version: version, + NoOriginTls: d.noOriginTls, }) if err != nil { return "", errors.Wrapf(err, "failed to render the Caddy site of stack %q slot %q", stackName, slot) @@ -720,9 +756,15 @@ func deployComposeToHost( // whether a domain is configured but from whether it is actually SERVED — a stack // whose domain was ignored for one of the reasons warned about above must not // export an https:// URL that answers nothing. + // + // The scheme follows the resource, not the hope: on `ingress.originTls: false` the + // machine answers on :80 and only the edge in front speaks HTTPS, so exporting + // `https://` here would hand an operator a URL that fails against the origin and + // works only once Cloudflare is actually in place. url := sdk.Sprintf("http://%s:%d", host.Address, hostPort) if servesDomain { - url = sdk.String("https://" + stackConfig.Domain).ToStringOutput() + scheme := lo.If(d.noOriginTls, "http").Else("https") + url = sdk.String(scheme + "://" + stackConfig.Domain).ToStringOutput() } // Exported by the caller, which is the only place that knows this host's index // in the fleet. @@ -771,6 +813,11 @@ type parentVmFacts struct { // filtering SC does not manage, or a parent stack provisioned before the // export existed. OpenPorts []vm.PortRange + // NoOriginTls is `ingress.originTls: false` on the resource: Caddy serves plain + // HTTP and runs no ACME. False on a parent stack that has never heard of the + // setting, which is the old behaviour and the reason the export carries this sense + // rather than its opposite. + NoOriginTls bool // Addresses is every machine's address, in host order — the same strings the // HostFacts carry, kept as literals because a DNS record's value has to be one. // HostFacts lifts the address into an Output for the driver side, where a cloud @@ -900,7 +947,21 @@ func (r parentReader) vmFacts() (parentVmFacts, error) { return parentVmFacts{}, errors.Wrapf(err, "failed to read the open ports of vm %q", r.vmResource) } - return parentVmFacts{IngressEnabled: ingressEnabled, OpenPorts: vm.ParsePortSpec(openPortsSpec)}, nil + // Same read-through-toBool treatment, same tolerance: an unreadable or absent + // value means "the machine terminates TLS", which is what every parent stack + // provisioned before this export did. + noOriginTlsRaw, err := pApi.GetValueFromStack[any](r.ctx, r.refName(0, "no-origin-tls"), r.parentRef, + ToIngressNoOriginTlsExport(r.vmResource), false) + if err != nil { + return parentVmFacts{}, errors.Wrapf(err, "failed to read the origin-TLS setting of vm %q", r.vmResource) + } + noOriginTls, _ := toBool(noOriginTlsRaw) + + return parentVmFacts{ + IngressEnabled: ingressEnabled, + NoOriginTls: noOriginTls, + OpenPorts: vm.ParsePortSpec(openPortsSpec), + }, nil } // host reads one machine of the fleet, returning its facts and — separately — its diff --git a/pkg/clouds/pulumi/vm/compose_stack_test.go b/pkg/clouds/pulumi/vm/compose_stack_test.go index 9f3faa8a..e9a8712d 100644 --- a/pkg/clouds/pulumi/vm/compose_stack_test.go +++ b/pkg/clouds/pulumi/vm/compose_stack_test.go @@ -5,6 +5,7 @@ package vm import ( "context" + "encoding/base64" "fmt" "strings" "sync" @@ -721,3 +722,99 @@ func TestVmCompose_TheShipKeepsTheDeployKeyOffTheCommandLine(t *testing.T) { Expect(logging.StringValue()).To(Equal("none"), "the ship streams a customer image over a connection authenticated by the key on stdin") } + +// withNoOriginTls models a machine the operator provisioned with +// `ingress.originTls: false`: Caddy fronts the stacks but terminates no TLS, because +// something in front of it does. +func (f *composeFixture) withNoOriginTls() *composeFixture { + f.mocks.outputs[resource.PropertyKey(ToIngressNoOriginTlsExport(testVmResource))] = resource.NewBoolProperty(true) + return f +} + +// `domainProxied: true` used to be refused in the convert tier for every vm resource. +// It is now decided here, against the parent — and both outcomes matter, because the +// refusal is the thing standing between an operator and a fleet whose certificates +// never issue, while the allow is the only front door available on a cloud with no +// bucket. +func TestVmCompose_DomainProxiedNeedsNoOriginTls(t *testing.T) { + RegisterTestingT(t) + + // The machine terminates TLS: a proxied domain sends the HTTP-01 challenge to + // Cloudflare's edge, so no certificate ever issues. Refused, with the remedy named. + refused := newComposeFixture().withIngress() + refused.in.Deployment.StackConfig.DomainProxied = lo.ToPtr(true) + _, err := refused.run() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("terminates TLS itself")) + Expect(err.Error()).To(ContainSubstring("ingress.originTls")) + + // The same stack onto a machine that declares no origin TLS: allowed, and the URL + // it hands back says http, because that is what the origin answers on. + allowed := newComposeFixture().withIngress().withNoOriginTls() + allowed.in.Deployment.StackConfig.DomainProxied = lo.ToPtr(true) + _, err = allowed.run() + Expect(err).To(BeNil()) + Expect(allowed.url).To(Equal("http://" + testDomain)) + + // `domainProxied: false` is a no-op and must not be refused on either machine — + // it says "do not proxy", which is already what SC does. + unproxied := newComposeFixture().withIngress() + unproxied.in.Deployment.StackConfig.DomainProxied = lo.ToPtr(false) + _, err = unproxied.run() + Expect(err).To(BeNil()) +} + +// The scheme has to reach the site file too, not just the exported URL: the site +// address is what suppresses automatic HTTPS for that site and puts the listener on +// :80. A deploy that exported `http://` and wrote an HTTPS site block would answer +// nothing at all. +func TestVmCompose_NoOriginTlsRendersAnHttpSite(t *testing.T) { + RegisterTestingT(t) + + f := newComposeFixture().withIngress().withNoOriginTls() + _, err := f.run() + Expect(err).To(BeNil()) + Expect(f.url).To(Equal("http://" + testDomain)) + + route := f.mocks.commands()["route"] + Expect(route).ToNot(BeNil(), "a stack whose domain is served must write a site file") + create, _ := unwrapSecret(route["create"]) + // The site file travels inside the script base64-encoded (it is written with + // `printf | base64 -d`, which is what keeps a multi-line config out of the shell's + // quoting rules), so the assertion has to decode it rather than grep the script. + Expect(siteFromRouteScript(create.StringValue())).To(ContainSubstring("http://" + testDomain + " {")) +} + +// Backwards compatibility at the seam that actually carries the risk: a parent stack +// provisioned before `ingress.originTls` existed exports nothing for it, and +// GetValueFromStack returns the zero value for an output that is not there. The zero +// has to mean "the machine terminates TLS" — the behaviour that fleet already had. +// Exporting the positive sense instead would have flipped every un-re-provisioned +// fleet to plain HTTP on its next deploy. +func TestVmCompose_OldParentStackStillServesHttps(t *testing.T) { + RegisterTestingT(t) + + f := newComposeFixture().withIngress() + // withIngress sets the ingress export and nothing else, so the origin-TLS output + // is genuinely absent here — the same state an old parent stack presents. + Expect(f.mocks.outputs).ToNot(HaveKey( + resource.PropertyKey(ToIngressNoOriginTlsExport(testVmResource)), + )) + + _, err := f.run() + Expect(err).To(BeNil()) + Expect(f.url).To(Equal("https://" + testDomain)) +} + +// siteFromRouteScript pulls the rendered Caddy site back out of a route command. +func siteFromRouteScript(script string) string { + const marker = "printf %s '" + i := strings.Index(script, marker) + Expect(i).To(BeNumerically(">=", 0), "the route script must embed the site file") + rest := script[i+len(marker):] + j := strings.Index(rest, "'") + Expect(j).To(BeNumerically(">=", 0), "the embedded site file must be quoted") + decoded, err := base64.StdEncoding.DecodeString(rest[:j]) + Expect(err).ToNot(HaveOccurred()) + return string(decoded) +} diff --git a/pkg/clouds/pulumi/vm/dns.go b/pkg/clouds/pulumi/vm/dns.go index 15e623d3..c5f22f8f 100644 --- a/pkg/clouds/pulumi/vm/dns.go +++ b/pkg/clouds/pulumi/vm/dns.go @@ -60,8 +60,8 @@ func fleetDnsRecords( // failure with the cause two stacks away. params.Log.Warn(ctx.Context(), "stack %q claims domain %q but the parent stack declares no registrar, so SC creates no DNS record. "+ - "Point %q at %s yourself, unproxied — the machine answers the ACME challenge itself", - stackName, domain, domain, addresses) + "Point %q at %s yourself, %s", + stackName, domain, domain, addresses, manualRecordAdvice(proxied)) return nil } @@ -79,8 +79,8 @@ func fleetDnsRecords( // descriptor exists but is not configured. params.Log.Warn(ctx.Context(), "stack %q claims domain %q but the parent stack's registrar is not configured, so SC creates "+ - "no DNS record. Point %q at %s yourself, unproxied", - stackName, domain, domain, addresses) + "no DNS record. Point %q at %s yourself, %s", + stackName, domain, domain, addresses, manualRecordAdvice(proxied)) return nil } return errors.Wrapf(err, "failed to create the A record for %q at %s (host %d of the fleet)", @@ -137,3 +137,23 @@ func fleetDnsRecords( stackName, domain, len(addresses), addresses, len(addresses)) return nil } + +// manualRecordAdvice is what to tell an operator who has to create the records by hand +// because the parent stack has no usable registrar. +// +// Which half is right depends on the fleet's front door, and the wrong half is not +// merely unhelpful. Told to proxy a fleet that terminates its own TLS, they get a name +// whose HTTP-01 challenge is answered at Cloudflare's edge instead of on the machine, so +// no certificate ever issues and Caddy retries forever. Told NOT to proxy a fleet +// running `ingress.originTls: false`, they point clients straight at an origin that +// serves plain HTTP on :80 by design — the edge that was supposed to terminate TLS is +// bypassed, and the only thing that was ever protecting that hop is gone. +// +// Both halves used to be one hardcoded "unproxied", correct until #566 made +// `domainProxied: true` reachable. +func manualRecordAdvice(proxied bool) string { + if proxied { + return "proxied — the fleet serves plain HTTP and the edge in front of it terminates TLS" + } + return "unproxied — the machines answer the ACME challenge themselves" +} diff --git a/pkg/clouds/pulumi/vm/dns_test.go b/pkg/clouds/pulumi/vm/dns_test.go index 62c21131..d08ecac9 100644 --- a/pkg/clouds/pulumi/vm/dns_test.go +++ b/pkg/clouds/pulumi/vm/dns_test.go @@ -10,6 +10,7 @@ import ( . "github.com/onsi/gomega" "github.com/pkg/errors" + "github.com/samber/lo" sdk "github.com/pulumi/pulumi/sdk/v3/go/pulumi" @@ -81,12 +82,19 @@ func collapsedRefs(int) sdk.Output { // runFleetDns calls fleetDnsRecords inside a Pulumi run, which it needs because the // guard awaits the references it is handed. func runFleetDns(reg pApi.Registrar, addresses []string) error { + return runFleetDnsWith(reg, addresses, false) +} + +// runFleetDnsWith is runFleetDns with the proxied flag under the test's control, which +// matters since #566: `domainProxied: true` is reachable on a fleet that terminates no +// origin TLS. +func runFleetDnsWith(reg pApi.Registrar, addresses []string, proxied bool) error { return sdk.RunErr(func(ctx *sdk.Context) error { params := pApi.ProvisionParams{Log: logger.New()} if reg != nil { params.Registrar = reg } - return fleetDnsRecords(ctx, params, testComposeStack, testDomain, addresses, false) + return fleetDnsRecords(ctx, params, testComposeStack, testDomain, addresses, proxied) }, sdk.WithMocks("project", testComposeStack, newComposeMocks())) } @@ -256,3 +264,69 @@ func TestFleetDns_NothingToDo(t *testing.T) { }, sdk.WithMocks("project", testComposeStack, newComposeMocks()))).To(Succeed()) Expect(reg.records).To(BeEmpty()) } + +// The front door #566 opened, asserted at the far end of the wiring it depends on. +// +// `ingress.originTls: false` means the machines serve plain HTTP and the edge in front +// terminates TLS — which only works if the records are actually PROXIED. Nothing before +// this test checked that `domainProxied` reaches `api.DnsRecord`, and the failure would +// have been silent in the worst way: a green deploy, a name that resolves, an edge that +// is never in the path, and every client talking cleartext to the origin over the public +// internet. Asserted through VmComposeStack rather than on fleetDnsRecords, because a +// flag that is read correctly and then dropped on the way down is exactly the bug. +func TestVmCompose_ProxiedReachesEveryRecordOfTheFleet(t *testing.T) { + RegisterTestingT(t) + + reg := &fakeRegistrar{ref: distinctRefs} + f := newComposeFixture().withIngress().withNoOriginTls().withHosts(3) + f.in.Deployment.StackConfig.DomainProxied = lo.ToPtr(true) + f.registrar = reg + + _, err := f.run() + Expect(err).ToNot(HaveOccurred()) + + Expect(reg.records).To(HaveLen(3)) + for i, rec := range reg.records { + Expect(rec.Proxied).To(BeTrue(), "record %d of a no-origin-TLS fleet must be proxied", i) + } + + // And the default is unchanged: a fleet that terminates its own TLS gets unproxied + // records, because Caddy answers the HTTP-01 challenge on the machine. + own := &fakeRegistrar{ref: distinctRefs} + g := newComposeFixture().withIngress().withHosts(2) + g.registrar = own + + _, err = g.run() + Expect(err).ToNot(HaveOccurred()) + Expect(own.records).To(HaveLen(2)) + for i, rec := range own.records { + Expect(rec.Proxied).To(BeFalse(), "record %d of a TLS-terminating fleet must not be proxied", i) + } +} + +// The flag reaches the record on the direct path too, where a caller with no fleet to +// deploy onto still has to get it right. +func TestFleetDns_ProxiedIsPassedThrough(t *testing.T) { + RegisterTestingT(t) + + reg := &fakeRegistrar{ref: distinctRefs} + Expect(runFleetDnsWith(reg, []string{"10.0.0.1", "10.0.0.2"}, true)).To(Succeed()) + Expect(reg.records).To(HaveLen(2)) + for i, rec := range reg.records { + Expect(rec.Proxied).To(BeTrue(), "record %d", i) + } +} + +// The advice SC prints when it cannot create the records itself. Both halves are +// asserted because both are reachable and the wrong one is harmful rather than merely +// unhelpful — see manualRecordAdvice's own comment for which way each fails. +func TestManualRecordAdvice(t *testing.T) { + RegisterTestingT(t) + + Expect(manualRecordAdvice(true)).To(HavePrefix("proxied")) + Expect(manualRecordAdvice(true)).To(ContainSubstring("terminates TLS")) + Expect(manualRecordAdvice(true)).ToNot(ContainSubstring("ACME")) + + Expect(manualRecordAdvice(false)).To(HavePrefix("unproxied")) + Expect(manualRecordAdvice(false)).To(ContainSubstring("ACME challenge")) +} diff --git a/pkg/clouds/pulumi/vm/vm.go b/pkg/clouds/pulumi/vm/vm.go index 0cd2f5c1..39a769f2 100644 --- a/pkg/clouds/pulumi/vm/vm.go +++ b/pkg/clouds/pulumi/vm/vm.go @@ -162,6 +162,12 @@ func Vm(ctx *sdk.Context, stack api.Stack, input api.ResourceInput, params pApi. } ctx.Export(ToHostCountExport(input.Descriptor.Name), sdk.Int(len(hosts))) ctx.Export(ToIngressEnabledExport(input.Descriptor.Name), sdk.Bool(cfg.IngressEnabled())) + // The NEGATION of cfg.OriginTlsEnabled(), and that is deliberate. A child stack + // deployed against a parent provisioned before this export existed reads a missing + // output as the zero value, so the zero has to be the old behaviour — and the old + // behaviour is that the machine terminates TLS. Exporting the positive sense would + // make every not-yet-re-provisioned fleet silently switch to plain HTTP. + ctx.Export(ToIngressNoOriginTlsExport(input.Descriptor.Name), sdk.Bool(!cfg.OriginTlsEnabled())) // What SC actually opened, so a child stack can tell its operator that the host // port it is about to publish is not reachable. Empty when SC does not manage // this machine's firewall — the child must then say nothing rather than guess, @@ -207,6 +213,10 @@ func ToIngressEnabledExport(vmName string) string { return fmt.Sprintf("%s-vm-ingress-enabled", vmName) } +func ToIngressNoOriginTlsExport(vmName string) string { + return fmt.Sprintf("%s-vm-ingress-no-origin-tls", vmName) +} + func ToOpenPortsExport(vmName string) string { return fmt.Sprintf("%s-vm-open-ports", vmName) } diff --git a/pkg/clouds/pulumi/vm/vm_test.go b/pkg/clouds/pulumi/vm/vm_test.go index b24ae701..6b6ded08 100644 --- a/pkg/clouds/pulumi/vm/vm_test.go +++ b/pkg/clouds/pulumi/vm/vm_test.go @@ -494,6 +494,7 @@ func TestVm_ExportsHostFacts(t *testing.T) { Expect(ToHostAddressExport("portal-vm", 0)).To(Equal("portal-vm-vm-host-0-address")) Expect(ToHostPrivateKeyExport("portal-vm", 0)).To(Equal("portal-vm-vm-host-0-private-key")) Expect(ToIngressEnabledExport("portal-vm")).To(Equal("portal-vm-vm-ingress-enabled")) + Expect(ToIngressNoOriginTlsExport("portal-vm")).To(Equal("portal-vm-vm-ingress-no-origin-tls")) } // Vm is reachable on its own, so it re-checks what Provider checked: a nil provider diff --git a/pkg/clouds/vm/caddy.go b/pkg/clouds/vm/caddy.go index 110bb5a0..57f11d72 100644 --- a/pkg/clouds/vm/caddy.go +++ b/pkg/clouds/vm/caddy.go @@ -66,6 +66,27 @@ func RenderCaddyfile(cfg *VmConfig) (string, error) { b := &strings.Builder{} b.WriteString(caddyGeneratedHeader) b.WriteString("\n{\n") + if !cfg.OriginTlsEnabled() { + // `auto_https off` rather than relying on every site address carrying an + // explicit `http://`. Both would stop ACME today, and the scheme on the site + // address is what actually makes the port 80 listener happen — but this is a + // file an operator edits and a conf.d an operator drops files into, and one + // bare domain in one hand-written site block is otherwise enough to send the + // machine off to Let's Encrypt for a certificate it cannot validate. Off at + // the global level is the statement that this machine does no TLS, in the one + // place that cannot be contradicted from below. + // + // It also disables the HTTP→HTTPS redirect, which is the point: there is no + // HTTPS here to redirect to. The edge in front is what serves it. + fmt.Fprintf(b, "\t# `ingress.originTls: false` on this vm resource: something in front terminates TLS\n") + fmt.Fprintf(b, "\t# (Cloudflare's proxy, a load balancer), so this machine serves plain HTTP on :80,\n") + fmt.Fprintf(b, "\t# requests no certificate, and has no ACME account. Edge-to-origin is CLEARTEXT —\n") + fmt.Fprintf(b, "\t# the machine's firewall is what has to admit only the edge.\n") + b.WriteString("\tauto_https off\n") + b.WriteString("}\n\n") + writeCaddySnippets(b, snippets) + return b.String(), nil + } fmt.Fprintf(b, "\temail %s\n", caddyToken(cfg.Ingress.Email)) if cfg.Ingress.AcmeCa != "" { // An `acme_ca` that does not speak ACME, or is unreachable, presents as @@ -80,8 +101,20 @@ func RenderCaddyfile(cfg *VmConfig) (string, error) { // substituted by Caddy's own parser at load time. fmt.Fprintf(b, "\tacme_eab {\n\t\tkey_id {$%s}\n\t\tmac_key {$%s}\n\t}\n", EnvAcmeEabKeyID, EnvAcmeEabMacKey) } + if cfg.AcmeStorageEnabled() { + writeCaddyAcmeStorage(b, cfg.Ingress.AcmeStorage) + } b.WriteString("}\n\n") + writeCaddySnippets(b, snippets) + return b.String(), nil +} +// writeCaddySnippets writes everything below the global options block: the inlined +// snippet library and the conf.d import. Shared by both exits of RenderCaddyfile so the +// no-origin-TLS machine cannot drift into having a different snippet set — a +// `siteExtraHelpers` that resolves on one machine and not another is a 500 on one third +// of a fleet's requests. +func writeCaddySnippets(b *strings.Builder, snippets []byte) { fmt.Fprintf(b, "# Shared snippet library, inlined from %s in the SC binary.\n", "pkg/clouds/caddy/"+caddyassets.CaddyfilePath) fmt.Fprintf(b, "# Identical to the one the Kubernetes edge serves, so `lbConfig.extraHelpers`\n") @@ -91,7 +124,67 @@ func RenderCaddyfile(cfg *VmConfig) (string, error) { fmt.Fprintf(b, "# One file per stack-env, each written by that stack's own deploy.\n") fmt.Fprintf(b, "import %s/*.caddy\n", CaddyContainerConfigDir+"/conf.d") - return b.String(), nil +} + +// writeCaddyAcmeStorage renders the global `storage s3` block — the thing that makes +// origin HTTPS possible on a fleet at all, rather than a tuning knob. +// +// Module: github.com/techknowlogick/certmagic-s3, baked into simplecontainer/caddy and +// gated by the image build's `caddy list-modules | grep -qE '^caddy\.storage\.s3$'`. +// An SC binary newer than the image it names will render a block the image's Caddy +// cannot parse, and Caddy answers that with a config-load failure at container start — +// which is loud, and the correct way for that skew to present. +// +// Two rendering rules, both forced by the module's own parser (s3.go, UnmarshalCaddyfile): +// +// - EVERY BOOLEAN CARRIES AN EXPLICIT VALUE. The parser reads each subdirective with +// `if !d.Args(&value) { continue }`, so a bare `use_path_style` is not an error and +// is not true — it is SILENTLY SKIPPED. A fleet on minio with a bare `use_path_style` +// would therefore address buckets virtual-host style, fail every request, and say +// nothing about why. +// - CREDENTIALS ARE `{$VAR}` PLACEHOLDERS, resolved by Caddy's parser at load time from +// the 0600 env file (RenderCaddyEnvFile). Same reasoning as `acme_eab` above: the +// Caddyfile is world-readable inside the container and is echoed by `caddy adapt`. +// They do not go through caddyToken, which would quote the braces into a literal. +func writeCaddyAcmeStorage(b *strings.Builder, s *VmAcmeStorage) { + fmt.Fprintf(b, "\t# Shared certificate store — every machine of this fleet points at this one\n") + fmt.Fprintf(b, "\t# bucket. That is what lets HTTP-01 work behind N A records: certmagic keeps\n") + fmt.Fprintf(b, "\t# the challenge state in storage, so whichever machine the CA happens to dial\n") + fmt.Fprintf(b, "\t# can answer a token a sibling minted, and the fleet holds ONE certificate.\n") + fmt.Fprintf(b, "\t# If certificates never issue, read `docker logs %s` on every machine, not one.\n", CaddyContainer) + b.WriteString("\tstorage s3 {\n") + fmt.Fprintf(b, "\t\tbucket %s\n", caddyToken(s.Bucket)) + if s.Endpoint != "" { + fmt.Fprintf(b, "\t\tendpoint %s\n", caddyToken(s.Endpoint)) + } + fmt.Fprintf(b, "\t\tregion %s\n", caddyToken(s.StorageRegion())) + fmt.Fprintf(b, "\t\tprefix %s\n", caddyToken(s.StoragePrefix())) + // Always rendered, never bare: the effective default is SC's (true whenever a custom + // endpoint is set), not the module's, so writing it out is also the only way a reader + // of the generated file can tell which addressing style is in force. + fmt.Fprintf(b, "\t\tuse_path_style %t\n", s.UsePathStyle()) + if s.Insecure { + fmt.Fprintf(b, "\t\t# TLS verification disabled on the vm resource (`ingress.acmeStorage.insecure`).\n") + b.WriteString("\t\tinsecure true\n") + } + if s.AccessKey != "" { + // Both or neither — validateAcmeStorage refuses one without the other, because + // the module only uses static credentials when both are set and otherwise falls + // through to the AWS default chain, which on a VPS finds nothing. + fmt.Fprintf(b, "\t\taccess_key {$%s}\n", EnvAcmeS3AccessKey) + fmt.Fprintf(b, "\t\tsecret_key {$%s}\n", EnvAcmeS3SecretKey) + } + if s.EncryptionKey != "" { + fmt.Fprintf(b, "\t\tencryption_key {$%s}\n", EnvAcmeS3EncryptionKey) + } else { + // The module logs `Clear text certificate storage active` on every start when + // this is absent. That is expected here and not a misconfiguration: the bucket + // is the operator's own and its contents are the fleet's own certificates. + fmt.Fprintf(b, "\t\t# No encryption_key: certificates are stored in clear text. Caddy says so\n") + fmt.Fprintf(b, "\t\t# on every start. Set `ingress.acmeStorage.encryptionKey` (exactly %d bytes)\n", AcmeStorageEncryptionKeyLen) + fmt.Fprintf(b, "\t\t# to encrypt them at rest.\n") + } + b.WriteString("\t}\n") } // RenderCaddyDefaultSite is the catch-all site, written once with the ingress. @@ -177,6 +270,20 @@ func RenderCaddyEnvFile(cfg *VmConfig) string { fmt.Fprintf(b, "%s=%s\n", EnvAcmeEabKeyID, cfg.Ingress.AcmeEabKeyID) fmt.Fprintf(b, "%s=%s\n", EnvAcmeEabMacKey, cfg.Ingress.AcmeEabMacKey) } + if cfg != nil && cfg.AcmeStorageEnabled() { + s := cfg.Ingress.AcmeStorage + // Only when both are set: the `storage s3` block renders the two placeholders as + // a pair, and a placeholder with no value in this file expands to the empty + // string rather than failing — which would read as "the credential is wrong" + // instead of "the credential is missing". + if s.AccessKey != "" { + fmt.Fprintf(b, "%s=%s\n", EnvAcmeS3AccessKey, s.AccessKey) + fmt.Fprintf(b, "%s=%s\n", EnvAcmeS3SecretKey, s.SecretKey) + } + if s.EncryptionKey != "" { + fmt.Fprintf(b, "%s=%s\n", EnvAcmeS3EncryptionKey, s.EncryptionKey) + } + } return b.String() } @@ -330,8 +437,13 @@ func RenderCaddySite(in *VmComposeInput, opts RenderOptions) (string, error) { proxy.WriteString("}") imports := []string{"import gzip", "import handle_static"} - // Unconditional, unlike Kubernetes' `useSSL`. There is nothing in front of this - // Caddy, so it is the thing that terminates TLS and the site is always HTTPS — + // Kept even when this machine serves plain HTTP, unlike Kubernetes' `useSSL`. + // With origin TLS the reasoning is the obvious one: this Caddy terminates, so the + // site is HTTPS and HSTS is its own to send. Without it, the header still reaches + // the browser over the EDGE's TLS, where it is honoured and stops a downgrade — + // and on the pathological arrangement (no origin TLS and no edge either) a + // browser ignores HSTS received over cleartext, so it is inert rather than + // harmful. There is no case where dropping it is better. // `lbConfig.https` describes what an external load balancer terminates and has // nothing to answer here. imports = append(imports, "import hsts") @@ -340,7 +452,19 @@ func RenderCaddySite(in *VmComposeInput, opts RenderOptions) (string, error) { b.WriteString(fmt.Sprintf(caddySiteHeader, opts.Stack, opts.Env, opts.Slot)) // The bare domain as the site address: Caddy's automatic HTTPS then provisions a // certificate for it and redirects HTTP to HTTPS by itself. - fmt.Fprintf(b, "%s {\n", sc.Domain) + // + // `http://` instead when the resource sets `ingress.originTls: false`. + // The scheme is not decoration — it is what suppresses automatic HTTPS for this + // site and puts the listener on :80. The global block's `auto_https off` says the + // same thing for the machine; both are rendered, because this file is written by + // the stack's deploy and that one by the resource's provision, and a machine that + // has not been re-provisioned since the flag was set would otherwise go asking a + // CA for a certificate on behalf of a stack that cannot answer the challenge. + if opts.NoOriginTls { + fmt.Fprintf(b, "http://%s {\n", sc.Domain) + } else { + fmt.Fprintf(b, "%s {\n", sc.Domain) + } if sc.Prefix == "" { writeIndented(b, proxy.String(), " ") for _, imp := range imports { diff --git a/pkg/clouds/vm/caddy_test.go b/pkg/clouds/vm/caddy_test.go index bf32e338..b9091baa 100644 --- a/pkg/clouds/vm/caddy_test.go +++ b/pkg/clouds/vm/caddy_test.go @@ -92,6 +92,120 @@ func TestRenderCaddyfileKeepsTheEabMacKeyOutOfTheConfig(t *testing.T) { Expect(env).To(ContainSubstring(EnvAcmeEabMacKey + "=s3cr3t-mac-key")) } +// The shared certificate store, rendered per backend. On a fleet this block is what +// makes origin HTTPS possible at all — see writeCaddyAcmeStorage. +func TestRenderCaddyfileSharedAcmeStorage(t *testing.T) { + RegisterTestingT(t) + + // Yandex Object Storage: a custom endpoint, so path-style addressing, and HMAC + // credentials that must not appear in the config. + cfg := ingressConfig() + cfg.Ingress.AcmeStorage = &VmAcmeStorage{ + Bucket: "sc-acme", + Endpoint: "https://storage.yandexcloud.net", + AccessKey: "YCAJEaccesskey", + SecretKey: "YCNsecretkeyvalue", + } + + got, err := RenderCaddyfile(cfg) + Expect(err).ToNot(HaveOccurred()) + + Expect(got).To(ContainSubstring("storage s3 {")) + Expect(got).To(ContainSubstring("bucket sc-acme")) + Expect(got).To(ContainSubstring("endpoint https://storage.yandexcloud.net")) + Expect(got).To(ContainSubstring("region " + DefaultAcmeStorageRegion)) + Expect(got).To(ContainSubstring("prefix " + DefaultAcmeStoragePrefix)) + Expect(got).To(ContainSubstring("access_key {$" + EnvAcmeS3AccessKey + "}")) + Expect(got).To(ContainSubstring("secret_key {$" + EnvAcmeS3SecretKey + "}")) + // Same discipline as the EAB mac key: the Caddyfile is 0640 on the machine and is + // echoed verbatim by `caddy adapt`. + Expect(got).ToNot(ContainSubstring("YCNsecretkeyvalue")) + Expect(got).ToNot(ContainSubstring("YCAJEaccesskey")) + // The block belongs to the GLOBAL options, not to a site. Caddy rejects `storage` + // at site level, so a misplaced block is a config-load failure on every machine. + Expect(strings.Index(got, "storage s3 {")).To(BeNumerically("<", strings.Index(got, "(gzip)"))) + + env := RenderCaddyEnvFile(cfg) + Expect(env).To(ContainSubstring(EnvAcmeS3AccessKey + "=YCAJEaccesskey")) + Expect(env).To(ContainSubstring(EnvAcmeS3SecretKey + "=YCNsecretkeyvalue")) + Expect(env).ToNot(ContainSubstring(EnvAcmeS3EncryptionKey)) + + // Real AWS S3: no endpoint, so virtual-host addressing and the operator's region. + aws := ingressConfig() + aws.Ingress.AcmeStorage = &VmAcmeStorage{Bucket: "sc-acme", Region: "eu-central-1", Prefix: "certs"} + got, err = RenderCaddyfile(aws) + Expect(err).ToNot(HaveOccurred()) + Expect(got).ToNot(ContainSubstring("endpoint ")) + Expect(got).To(ContainSubstring("region eu-central-1")) + Expect(got).To(ContainSubstring("prefix certs")) + Expect(got).To(ContainSubstring("use_path_style false")) + // No credentials given: the AWS default chain is the module's own fallback, and on + // EC2 it is the right answer. The pair must then be absent from BOTH files rather + // than rendered as placeholders that expand to nothing. + Expect(got).ToNot(ContainSubstring("access_key")) + Expect(got).ToNot(ContainSubstring("secret_key")) + Expect(RenderCaddyEnvFile(aws)).ToNot(ContainSubstring(EnvAcmeS3AccessKey)) + + // minio on host 0, self-signed: the only shape in which `insecure` is rendered. + minio := ingressConfig() + minio.Ingress.AcmeStorage = &VmAcmeStorage{ + Bucket: "sc-acme", + Endpoint: "https://10.0.0.5:9000", + AccessKey: "minioadmin", + SecretKey: "minioadmin", + Insecure: true, + EncryptionKey: strings.Repeat("e", AcmeStorageEncryptionKeyLen), + } + got, err = RenderCaddyfile(minio) + Expect(err).ToNot(HaveOccurred()) + Expect(got).To(ContainSubstring("use_path_style true")) + Expect(got).To(ContainSubstring("insecure true")) + Expect(got).To(ContainSubstring("encryption_key {$" + EnvAcmeS3EncryptionKey + "}")) + Expect(got).ToNot(ContainSubstring(strings.Repeat("e", AcmeStorageEncryptionKeyLen))) + Expect(RenderCaddyEnvFile(minio)).To(ContainSubstring( + EnvAcmeS3EncryptionKey + "=" + strings.Repeat("e", AcmeStorageEncryptionKeyLen), + )) + + // And absent entirely on a single machine, which is the common case. + bare, err := RenderCaddyfile(ingressConfig()) + Expect(err).ToNot(HaveOccurred()) + Expect(bare).ToNot(ContainSubstring("storage")) +} + +// certmagic-s3's UnmarshalCaddyfile reads every subdirective with +// `if !d.Args(&value) { continue }`. A bare `use_path_style` is therefore NOT an error +// and NOT true — it is silently skipped, and the only symptom is a fleet on minio whose +// every storage request fails because it addressed the bucket virtual-host style. +// +// So: no boolean is ever rendered without a value. This test asserts the property +// directly rather than trusting the three cases above to keep covering it. +func TestRenderCaddyfileNeverRendersABareBoolean(t *testing.T) { + RegisterTestingT(t) + + cfg := ingressConfig() + cfg.Ingress.AcmeStorage = &VmAcmeStorage{ + Bucket: "sc-acme", + Endpoint: "https://10.0.0.5:9000", + AccessKey: "minioadmin", + SecretKey: "minioadmin", + Insecure: true, + } + got, err := RenderCaddyfile(cfg) + Expect(err).ToNot(HaveOccurred()) + + for _, line := range strings.Split(got, "\n") { + fields := strings.Fields(line) + if len(fields) == 0 || strings.HasPrefix(fields[0], "#") { + continue + } + if !lo.Contains([]string{"use_path_style", "insecure"}, fields[0]) { + continue + } + Expect(fields).To(HaveLen(2), "%q must carry an explicit value — certmagic-s3 silently skips a valueless subdirective", line) + Expect(fields[1]).To(BeElementOf("true", "false")) + } +} + // Written even when empty: compose refuses a project whose `env_file` is missing, so // an ingress with no EAB would otherwise stop starting the moment the file was not // created. @@ -371,3 +485,66 @@ func TestRenderCaddySiteRefusals(t *testing.T) { Expect(err).To(HaveOccurred(), "%s must be refused", name) } } + +// `ingress.originTls: false`: the machine serves plain HTTP for an edge that +// terminates, and the global block has to say so in a way that cannot be contradicted +// by a hand-written site block in conf.d. +func TestRenderCaddyfileNoOriginTls(t *testing.T) { + RegisterTestingT(t) + + cfg := ingressConfig() + cfg.Ingress.Email = "" + cfg.Ingress.OriginTls = lo.ToPtr(false) + + got, err := RenderCaddyfile(cfg) + Expect(err).ToNot(HaveOccurred()) + + // The whole point: no automatic HTTPS anywhere on this machine. Without it one + // bare domain in one conf.d file sends the machine off to a CA for a certificate + // whose challenge it cannot answer, and the fleet quietly earns a rate-limit. + Expect(got).To(ContainSubstring("auto_https off")) + // Nothing ACME-shaped is rendered, because nothing requests a certificate. + Expect(got).ToNot(ContainSubstring("email ")) + Expect(got).ToNot(ContainSubstring("acme_ca")) + Expect(got).ToNot(ContainSubstring("acme_eab")) + Expect(got).ToNot(ContainSubstring("storage s3")) + // The hop the operator is now responsible for, named in the file they will read. + Expect(got).To(ContainSubstring("CLEARTEXT")) + // The snippet library and the conf.d import are NOT a casualty of the early + // return — a `siteExtraHelpers` that resolves on an HTTPS machine and not on this + // one would be a 500 on a fraction of a fleet's requests. + for _, snippet := range []string{"(gzip)", "(hsts)", "(handle_static)", "(handle_server_error)"} { + Expect(got).To(ContainSubstring(snippet), "snippet %s must be inlined here too", snippet) + } + Expect(got).To(ContainSubstring("import " + CaddyContainerConfigDir + "/conf.d/*.caddy")) +} + +// The site file's scheme. Both halves are asserted against the SAME stack input, so a +// regression that renders one scheme unconditionally fails one of them. +func TestRenderCaddySiteSchemeFollowsOriginTls(t *testing.T) { + RegisterTestingT(t) + + in := renderTestInput(t) + domain := in.Deployment.StackConfig.Domain + Expect(domain).ToNot(BeEmpty()) + + https, err := RenderCaddySite(in, caddySiteOpts()) + Expect(err).ToNot(HaveOccurred()) + Expect(https).To(ContainSubstring(domain + " {")) + Expect(https).ToNot(ContainSubstring("http://" + domain)) + + opts := caddySiteOpts() + opts.NoOriginTls = true + plain, err := RenderCaddySite(in, opts) + Expect(err).ToNot(HaveOccurred()) + // The scheme is not decoration: it is what suppresses automatic HTTPS for this + // site and puts the listener on :80. + Expect(plain).To(ContainSubstring("http://" + domain + " {")) + // HSTS stays. It reaches the browser over the EDGE's TLS, where it is honoured; + // with no edge at all a browser ignores HSTS received over cleartext, so it is + // inert rather than harmful. There is no case where dropping it is better. + Expect(plain).To(ContainSubstring("import hsts")) + // And the upstream is untouched — the proxy hop was always plain HTTP over the + // docker network. + Expect(plain).To(ContainSubstring("reverse_proxy http://")) +} diff --git a/pkg/clouds/vm/convert.go b/pkg/clouds/vm/convert.go index 07671c3e..aed4d304 100644 --- a/pkg/clouds/vm/convert.go +++ b/pkg/clouds/vm/convert.go @@ -195,14 +195,19 @@ func toComposeTemplate(tpl any) (*ComposeTemplateConfig, error) { func (i *VmComposeInput) validateStackConfig() error { cfg := i.Deployment.StackConfig - if cfg.DomainProxied != nil { - return errors.Errorf( - "`domainProxied` is not supported by the `%s` template: it proxies the domain through "+ - "Cloudflare, and a VM serves TLS itself (Caddy obtains the certificate via ACME, which a "+ - "proxied domain breaks). Remove the field, or put the machine behind Cloudflare outside SC", - TemplateTypeVmCompose, - ) - } + // `domainProxied` is NOT checked here, and the absence is deliberate. It used to be + // refused outright with "a VM serves TLS itself, and a proxied domain breaks the + // ACME challenge" — true of every vm resource up to `ingress.originTls`, and now + // true of only some of them. Whether it holds is a property of the PARENT stack's + // resource, and this function runs in the convert tier with no parent reference at + // all, so the check moved to where the answer exists: VmComposeStack, after + // hostsFromParent has read the resource's exports (clouds/pulumi/vm/compose_stack.go). + // + // The cost of moving it is that an operator sees the refusal at `sc deploy` rather + // than at config load. The alternative — keeping a blanket refusal here — makes the + // one front-door arrangement that needs no shared certificate store unreachable, + // which is the opposite trade. + if cfg.ClusterIPAddress != nil { return errors.Errorf( "`clusterIPAddress` is not applicable to the `%s` template: there is no cluster. The stack is "+ diff --git a/pkg/clouds/vm/convert_test.go b/pkg/clouds/vm/convert_test.go index 24a6d401..644cfc09 100644 --- a/pkg/clouds/vm/convert_test.go +++ b/pkg/clouds/vm/convert_test.go @@ -110,21 +110,13 @@ func TestToVmComposeConfigRefusals(t *testing.T) { stackCfg *api.StackConfigCompose msg string }{ - { - name: "domainProxied breaks ACME", - stackCfg: &api.StackConfigCompose{ - Runs: []string{"backend"}, DomainProxied: lo.ToPtr(true), - }, - msg: "`domainProxied` is not supported", - }, - { - // Even `false` is refused: accepting it would imply SC understood the field. - name: "domainProxied false is still refused", - stackCfg: &api.StackConfigCompose{ - Runs: []string{"backend"}, DomainProxied: lo.ToPtr(false), - }, - msg: "`domainProxied` is not supported", - }, + // `domainProxied` was refused here in both senses until `ingress.originTls` + // existed. It is now decided at deploy time against the parent resource — + // TestVmCompose_DomainProxiedNeedsNoOriginTls in clouds/pulumi/vm covers both + // outcomes — because this tier cannot see the parent. The two cases that used + // to live here are deliberately gone rather than relaxed to a warning: a + // warning on `domainProxied: true` would fire on the arrangement that is now + // correct. { name: "clusterIPAddress has no cluster", stackCfg: &api.StackConfigCompose{ diff --git a/pkg/clouds/vm/names.go b/pkg/clouds/vm/names.go index 8816f62c..443c784f 100644 --- a/pkg/clouds/vm/names.go +++ b/pkg/clouds/vm/names.go @@ -110,6 +110,31 @@ func CaddyDefaultSitePath() string { const ( EnvAcmeEabKeyID = "SC_ACME_EAB_KEY_ID" EnvAcmeEabMacKey = "SC_ACME_EAB_MAC_KEY" + // The shared certificate store's credential. SC_ACME_S3_ACCESS_KEY is not itself + // secret, but it goes through the env file with the other two so that the three + // lines of the `storage` block that carry credentials look the same in the + // generated Caddyfile — a reader should not have to know which of them is safe. + EnvAcmeS3AccessKey = "SC_ACME_S3_ACCESS_KEY" + EnvAcmeS3SecretKey = "SC_ACME_S3_SECRET_KEY" + EnvAcmeS3EncryptionKey = "SC_ACME_S3_ENCRYPTION_KEY" +) + +// The shared certificate store's defaults, rendered explicitly rather than left to the +// module so that the generated Caddyfile says where the certificates are. +const ( + // DefaultAcmeStoragePrefix matches certmagic-s3's own default. Named here because + // an operator reading one fleet's prefix needs to know what the other fleet + // sharing the bucket is using. + DefaultAcmeStoragePrefix = "acme" + // DefaultAcmeStorageRegion applies only when an endpoint is set, which means the + // store is not real AWS and conventionally ignores the region. validateIngress + // refuses an empty region when there is no endpoint. + DefaultAcmeStorageRegion = "us-east-1" + // AcmeStorageEncryptionKeyLen is secretbox's key size. certmagic-s3 rejects any + // other length — at Caddyfile PARSE time for a literal, and at Provision time for + // a value that arrived through a placeholder — so SC checks it while the operator + // is still looking at the config. + AcmeStorageEncryptionKeyLen = 32 ) // The two blue-green slots. A stack-env occupies one of them at a time; a deploy diff --git a/pkg/clouds/vm/render.go b/pkg/clouds/vm/render.go index f019562f..676e1c6c 100644 --- a/pkg/clouds/vm/render.go +++ b/pkg/clouds/vm/render.go @@ -54,6 +54,16 @@ type RenderOptions struct { // Caddy exists (P5.2) and for `ingress.enable: false` (§8.4); 0 publishes nothing // and leaves the service reachable only over the docker network. IngressHostPort int + // NoOriginTls renders the site on `http://` instead of on the bare domain, + // because `ingress.originTls: false` is set on the vm resource this stack deploys + // onto and the machine terminates no TLS. + // + // It arrives here as a field rather than being read off the vm config because + // RenderCaddySite is given the STACK's input and the flag belongs to the parent's + // resource; the deploy reads it out of the parent's exports and passes it down. + // Negative-named to match that export, where the zero value has to mean the + // behaviour every resource had before the field existed. + NoOriginTls bool } // RenderedCompose is what the deploy writes to the machine. diff --git a/pkg/clouds/vm/vm.go b/pkg/clouds/vm/vm.go index 63fff391..c350a19c 100644 --- a/pkg/clouds/vm/vm.go +++ b/pkg/clouds/vm/vm.go @@ -117,8 +117,31 @@ type VmNetwork struct { // stacks share one machine and therefore one port 443 (compose-on-vm.md §3, D15). type VmIngress struct { Enable bool `json:"enable" yaml:"enable"` - // Email is the ACME account address. Required when Enable is set, because a - // certificate authority will not issue without one. + // OriginTls is whether THIS machine terminates TLS. Unset means true, which is + // every vm resource that existed before this field. + // + // Set it to false when something in front already terminates — Cloudflare's + // proxy, a cloud load balancer, an existing reverse proxy — and Caddy then serves + // plain HTTP on port 80 and runs no ACME at all. That is the ONE front-door + // arrangement a fleet can have without a shared certificate store (see + // AcmeStorage, which explains why per-machine ACME behind N A records cannot + // work), so on a cloud with no bucket resource and no appetite for running minio + // it is not a downgrade, it is the option. + // + // It is also what makes `domainProxied: true` legal on a stack: that refusal + // exists because a proxied domain breaks the HTTP-01 challenge Caddy runs itself, + // and here Caddy runs none. The refusal therefore moved out of the convert tier + // (which cannot see this field — it belongs to the parent stack) and into the + // deploy, where the parent's exports are readable. + // + // What it does NOT do is secure the hop. Edge-to-origin is cleartext over the + // public internet unless the machine's firewall admits only the edge's addresses + // — Cloudflare publishes theirs, and `ingressPorts` on this resource is where + // they go. Said plainly because "my site is HTTPS" is true for the visitor and + // false for the leg SC is responsible for. + OriginTls *bool `json:"originTls,omitempty" yaml:"originTls,omitempty"` + // Email is the ACME account address. Required when Enable is set and this machine + // terminates TLS, because a certificate authority will not issue without one. Email string `json:"email,omitempty" yaml:"email,omitempty"` // Image is the Caddy image to run. Defaults to the same `simplecontainer/caddy` // tag the Kubernetes edge uses, which is what makes `lbConfig.siteExtraHelpers` @@ -158,6 +181,133 @@ type VmIngress struct { // the Pulumi checkpoint and out of a file an operator might paste into a ticket. AcmeEabKeyID string `json:"acmeEabKeyId,omitempty" yaml:"acmeEabKeyId,omitempty"` AcmeEabMacKey string `json:"acmeEabMacKey,omitempty" yaml:"acmeEabMacKey,omitempty"` + // AcmeStorage points every machine of this resource at ONE shared certificate + // store. Leave it unset for a single machine; set it for a fleet. + // + // On a fleet it is not a tuning knob, it is the thing that makes HTTPS possible. + // SC's Caddy image carries no `dns.*` provider, so HTTP-01 is the only ACME + // challenge available, and a name with N A records sends the CA's validator to an + // arbitrary machine — which 404s a token a sibling minted. certmagic's distributed + // solver shares that challenge state through the storage module, so one store turns + // N machines into one ACME client holding one certificate instead of N. Without it + // a fleet either serves plain HTTP on the origin (see `domainProxied`) or walks + // into a rate-limit lockout. + AcmeStorage *VmAcmeStorage `json:"acmeStorage,omitempty" yaml:"acmeStorage,omitempty"` +} + +// VmAcmeStorage is an S3-compatible bucket holding the fleet's certificates and ACME +// challenge state, rendered as Caddy's `storage s3` global option. +// +// S3 and not one module per cloud. The image also carries `caddy.storage.gcs`, but GCS +// serves an S3-compatible interop endpoint and `gcp-bucket` already exports HMAC +// credentials for it, so one code path covers Yandex, AWS, GCP, minio and anything +// else that speaks S3. The GCS module stays what the Kubernetes edge uses. There is +// deliberately no `type:` field: one legal value is not a choice, and adding the field +// later with a default of `s3` stays backwards compatible. +// +// Every field is a property of the RESOURCE, not of a machine — the whole point is +// that all machines write to the same place. +type VmAcmeStorage struct { + // Bucket is required. It must already exist: SC does not create it here, because + // the bucket for a fleet generally lives in the same parent stack (a `yc-bucket` / + // `gcp-bucket` resource) or on an operator-run minio, and inventing a second + // owner for it would make a `sc destroy` able to take the certificates with it. + Bucket string `json:"bucket" yaml:"bucket"` + // Endpoint is the store's base URL, scheme included + // (`https://storage.yandexcloud.net`, `http://10.0.0.4:9000`). Empty means real + // AWS S3. + // + // Setting it also switches the SDK to computing request checksums only when + // required, which is what makes most non-AWS implementations work at all. + Endpoint string `json:"endpoint,omitempty" yaml:"endpoint,omitempty"` + // Region is required when Endpoint is empty, since that is real AWS and the wrong + // region is a failed request rather than a redirect. With an Endpoint it defaults + // to `us-east-1`, which S3-compatible stores conventionally ignore. + Region string `json:"region,omitempty" yaml:"region,omitempty"` + // AccessKey / SecretKey are the static credential, both or neither. + // + // Neither means the AWS SDK's default chain — an EC2 instance role, in practice. + // That works on AWS and nowhere else: a VPS has no instance metadata, so on + // Cloud.ru, MWS or byo, omitting them is a silent fall-through to "no credentials" + // whose only symptom is certificates that never issue. SecretKey is a credential: + // write it as `${secret:...}`. Neither value reaches the rendered Caddyfile — both + // are delivered in the ingress env file (0600, over stdin) and referenced as + // `{$...}`, the same discipline as the EAB key above. + AccessKey string `json:"accessKey,omitempty" yaml:"accessKey,omitempty"` + SecretKey string `json:"secretKey,omitempty" yaml:"secretKey,omitempty"` + // Prefix is the key prefix inside the bucket. Defaults to `acme`. + // + // Worth setting when one bucket serves several fleets: two fleets sharing a prefix + // share locks and certificates, which is correct only if they also share every + // domain name. + Prefix string `json:"prefix,omitempty" yaml:"prefix,omitempty"` + // PathStyle selects path-style addressing (`//`) over + // virtual-host style (`./`). Defaults to TRUE whenever + // Endpoint is set and false otherwise. + // + // That default is the useful one: virtual-host addressing needs the bucket name to + // resolve as a DNS label under the endpoint, which minio does not serve at all and + // several S3-compatible clouds serve only for some bucket names. Real AWS prefers + // virtual-host. Set it explicitly to override. + PathStyle *bool `json:"pathStyle,omitempty" yaml:"pathStyle,omitempty"` + // Insecure disables TLS verification against the store. For a minio with a + // self-signed certificate, and nothing else — the credential and every private key + // the fleet owns travel over this connection. Prefer plain `http://` on a private + // network, which is at least honest about what it is. + Insecure bool `json:"insecure,omitempty" yaml:"insecure,omitempty"` + // EncryptionKey encrypts the stored objects with secretbox. Exactly 32 bytes when + // set; a credential, so write it as `${secret:...}`. + // + // Unset means the module logs `Clear text certificate storage active` and stores + // the fleet's private keys as they are. Acceptable when the bucket is private AND + // the cloud encrypts at rest; on a minio running on one of the fleet's own + // machines it is neither, so set it there. + EncryptionKey string `json:"encryptionKey,omitempty" yaml:"encryptionKey,omitempty"` +} + +// AcmeStorageEnabled reports whether this resource has a shared certificate store. +func (r *VmConfig) AcmeStorageEnabled() bool { + return r.IngressEnabled() && r.Ingress.AcmeStorage != nil && r.Ingress.AcmeStorage.Bucket != "" +} + +// OriginTlsEnabled reports whether this machine's Caddy terminates TLS. +// +// Defaults to true on an unset field and on no ingress at all, so that every caller +// reads "yes" for every resource that predates `ingress.originTls` — the flag only ever +// turns behaviour off, never on. The parent stack exports the NEGATION of this for the +// same reason (ToIngressNoOriginTlsExport): a child reading a parent that has never +// heard of the field gets a zero bool, and the zero has to mean the old behaviour. +func (r *VmConfig) OriginTlsEnabled() bool { + if !r.IngressEnabled() { + return true + } + return lo.FromPtrOr(r.Ingress.OriginTls, true) +} + +// UsePathStyle resolves PathStyle's default: on whenever there is an endpoint. +func (s *VmAcmeStorage) UsePathStyle() bool { + if s.PathStyle != nil { + return *s.PathStyle + } + return s.Endpoint != "" +} + +// StoragePrefix resolves Prefix's default. Rendered explicitly rather than left to the +// module, so the generated Caddyfile says where the fleet's certificates actually are. +func (s *VmAcmeStorage) StoragePrefix() string { + if s.Prefix != "" { + return s.Prefix + } + return DefaultAcmeStoragePrefix +} + +// StorageRegion resolves Region's default, which only applies when there is an endpoint +// — validateIngress refuses an empty region without one. +func (s *VmAcmeStorage) StorageRegion() string { + if s.Region != "" { + return s.Region + } + return DefaultAcmeStorageRegion } // VmHost is one machine the operator brought — the whole input of DriverByo. @@ -449,13 +599,48 @@ func (r *VmConfig) validateIngress() error { if !r.IngressEnabled() { return nil } - if r.Ingress.Email == "" { - return errors.Errorf("`ingress.enable: true` requires `ingress.email` (the ACME account address)") - } if d := r.EffectiveIngressImageDelivery(); d != ImageDeliveryRegistry && d != ImageDeliverySshTar { return errors.Errorf("unknown `ingress.imageDelivery` %q; one of: %s, %s", d, ImageDeliveryRegistry, ImageDeliverySshTar) } + if !r.OriginTlsEnabled() { + // Every ACME setting is refused rather than ignored, which is this file's + // standing rule and earns its keep here: an operator who sets `originTls: + // false` and leaves `acmeStorage` behind believes the fleet still holds a + // certificate, and would not find out otherwise until something in front of + // the machine went away. The remedy is one deleted line and the message names + // it. + // + // `email` is in the list because it has no other purpose on this path — it is + // the ACME account address and there is no ACME account. + for _, f := range []struct{ name, value string }{ + {"email", r.Ingress.Email}, + {"acmeCa", r.Ingress.AcmeCa}, + {"acmeEabKeyId", r.Ingress.AcmeEabKeyID}, + {"acmeEabMacKey", r.Ingress.AcmeEabMacKey}, + } { + if f.value != "" { + return errors.Errorf( + "`ingress.%s` is set together with `ingress.originTls: false`: this machine serves plain "+ + "HTTP and requests no certificate, so nothing would read it. Remove `ingress.%s`, or "+ + "remove `originTls: false` and let Caddy terminate TLS itself", f.name, f.name, + ) + } + } + if r.Ingress.AcmeStorage != nil { + return errors.Errorf( + "`ingress.acmeStorage` is set together with `ingress.originTls: false`: the shared store holds " + + "certificates and ACME challenge state, and this machine requests neither. These are the two " + + "alternative front doors for a fleet, not a pair — keep the store and drop `originTls: false` " + + "to serve HTTPS from the machines, or keep `originTls: false` and drop the store to serve " + + "plain HTTP behind an edge that terminates", + ) + } + return nil + } + if r.Ingress.Email == "" { + return errors.Errorf("`ingress.enable: true` requires `ingress.email` (the ACME account address)") + } if (r.Ingress.AcmeEabKeyID == "") != (r.Ingress.AcmeEabMacKey == "") { return errors.Errorf( "`ingress.acmeEabKeyId` and `ingress.acmeEabMacKey` must be set together: External Account " + @@ -469,6 +654,68 @@ func (r *VmConfig) validateIngress() error { "private or enterprise CA, and Let's Encrypt (the default) does not accept it", ) } + return r.validateAcmeStorage() +} + +// validateAcmeStorage checks the shared certificate store. +// +// Every refusal here replaces a failure that is otherwise invisible until a certificate +// does not renew, which is weeks after the deploy that caused it and on a machine nobody +// is watching. The store is only consulted on issuance and renewal, so a misconfigured +// one serves perfectly from cache until it matters. +func (r *VmConfig) validateAcmeStorage() error { + s := r.Ingress.AcmeStorage + if s == nil { + return nil + } + if s.Bucket == "" { + return errors.Errorf( + "`ingress.acmeStorage` is set without `bucket`: the shared certificate store needs a bucket " + + "that already exists (SC does not create it — see the field docs)", + ) + } + if (s.AccessKey == "") != (s.SecretKey == "") { + return errors.Errorf( + "`ingress.acmeStorage.accessKey` and `.secretKey` must be set together: the module uses the " + + "static credential only when it has both, and otherwise falls through to the AWS SDK's " + + "default chain — which on a VPS finds nothing and presents as certificates that never issue", + ) + } + if s.Endpoint == "" && s.Region == "" { + return errors.Errorf( + "`ingress.acmeStorage` has neither `endpoint` nor `region`: no endpoint means real AWS S3, "+ + "where the wrong region is a failed request rather than a redirect. Set `region`, or set "+ + "`endpoint` (then `region` defaults to %q, which S3-compatible stores ignore)", + DefaultAcmeStorageRegion, + ) + } + if s.Endpoint != "" && !strings.HasPrefix(s.Endpoint, "http://") && !strings.HasPrefix(s.Endpoint, "https://") { + return errors.Errorf( + "`ingress.acmeStorage.endpoint` %q has no scheme: it is used as the SDK's base endpoint and "+ + "must be a URL (`https://storage.yandexcloud.net`, `http://10.0.0.4:9000`)", + s.Endpoint, + ) + } + if k := s.EncryptionKey; k != "" && len(k) != AcmeStorageEncryptionKeyLen { + // Checked here even though the module checks it too, because the module's + // check lands in two different places depending on how the value arrives, and + // SC always makes it arrive the harder way: a literal fails Caddyfile parsing + // (visible, the deploy fails), but SC delivers this through the env file as a + // `{$...}` placeholder, and a substituted wrong-length key reaches `Provision` + // — which returns an error that is only in `docker logs sc-caddy`. + return errors.Errorf( + "`ingress.acmeStorage.encryptionKey` is %d bytes; secretbox needs exactly %d. Leave it unset "+ + "to store the fleet's private keys unencrypted (acceptable only when the bucket is private "+ + "and the cloud encrypts at rest)", + len(k), AcmeStorageEncryptionKeyLen, + ) + } + if s.Insecure && strings.HasPrefix(s.Endpoint, "http://") { + return errors.Errorf( + "`ingress.acmeStorage.insecure: true` with an `http://` endpoint: there is no TLS to skip " + + "verifying. Drop `insecure`", + ) + } return nil } diff --git a/pkg/clouds/vm/vm_test.go b/pkg/clouds/vm/vm_test.go index ad789331..a2d55790 100644 --- a/pkg/clouds/vm/vm_test.go +++ b/pkg/clouds/vm/vm_test.go @@ -4,6 +4,7 @@ package vm import ( + "strings" "testing" . "github.com/onsi/gomega" @@ -195,6 +196,148 @@ func TestValidate(t *testing.T) { } } +func TestValidateAcmeStorage(t *testing.T) { + RegisterTestingT(t) + + withStore := func(mutate func(*VmAcmeStorage)) *VmConfig { + cfg := byoConfig() + cfg.Ingress = &VmIngress{ + Enable: true, + Email: "ops@example.com", + AcmeStorage: &VmAcmeStorage{ + Bucket: "sc-acme", + Endpoint: "https://storage.yandexcloud.net", + AccessKey: "YCAJE...", + SecretKey: "YCN...", + }, + } + mutate(cfg.Ingress.AcmeStorage) + return cfg + } + + cases := []struct { + name string + mutate func(*VmAcmeStorage) + errMatch string + }{ + { + name: "a complete store", + mutate: func(*VmAcmeStorage) {}, + errMatch: "", + }, + { + name: "no bucket", + mutate: func(s *VmAcmeStorage) { s.Bucket = "" }, + errMatch: "is set without `bucket`", + }, + { + // One without the other is the worst shape of all: the module only uses + // static credentials when BOTH are set, so half a pair silently falls + // through to the AWS default chain. + name: "access key without secret", + mutate: func(s *VmAcmeStorage) { s.SecretKey = "" }, + errMatch: "`.secretKey` must be set together", + }, + { + name: "secret without access key", + mutate: func(s *VmAcmeStorage) { s.AccessKey = "" }, + errMatch: "`.secretKey` must be set together", + }, + { + name: "neither endpoint nor region", + mutate: func(s *VmAcmeStorage) { + s.Endpoint = "" + s.Region = "" + }, + errMatch: "has neither `endpoint` nor `region`", + }, + { + // No endpoint means real AWS S3, which is legitimate — but then the region + // is the only thing that routes the request. + name: "region alone is enough", + mutate: func(s *VmAcmeStorage) { + s.Endpoint = "" + s.Region = "eu-central-1" + }, + errMatch: "", + }, + { + // The module hands this straight to the SDK as BaseEndpoint, which needs a + // scheme. Without one the SDK fails per request, not at load. + name: "endpoint without a scheme", + mutate: func(s *VmAcmeStorage) { s.Endpoint = "storage.yandexcloud.net" }, + errMatch: "has no scheme", + }, + { + name: "encryption key of the wrong length", + mutate: func(s *VmAcmeStorage) { s.EncryptionKey = "too-short" }, + errMatch: "secretbox needs exactly 32", + }, + { + name: "encryption key of exactly 32 bytes", + mutate: func(s *VmAcmeStorage) { s.EncryptionKey = strings.Repeat("k", 32) }, + errMatch: "", + }, + { + name: "insecure against a plaintext endpoint", + mutate: func(s *VmAcmeStorage) { + s.Endpoint = "http://10.0.0.5:9000" + s.Insecure = true + }, + errMatch: "there is no TLS to skip verifying", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + RegisterTestingT(t) + + err := withStore(tc.mutate).Validate() + if tc.errMatch == "" { + Expect(err).ToNot(HaveOccurred()) + return + } + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring(tc.errMatch)) + }) + } +} + +// The three defaults SC computes rather than leaving to the module. `use_path_style` is +// the one that matters: the module's own default is false, which is right for AWS and +// wrong for every other S3 implementation — so SC derives it from whether a custom +// endpoint was given, and an operator can still override either way. +func TestAcmeStorageDefaults(t *testing.T) { + RegisterTestingT(t) + + aws := &VmAcmeStorage{Bucket: "b", Region: "eu-central-1"} + Expect(aws.UsePathStyle()).To(BeFalse()) + Expect(aws.StorageRegion()).To(Equal("eu-central-1")) + Expect(aws.StoragePrefix()).To(Equal(DefaultAcmeStoragePrefix)) + + minio := &VmAcmeStorage{Bucket: "b", Endpoint: "http://10.0.0.5:9000"} + Expect(minio.UsePathStyle()).To(BeTrue()) + // us-east-1 and not empty: the SDK requires a region even where the store ignores + // it, and the module would otherwise default it itself, out of SC's sight. + Expect(minio.StorageRegion()).To(Equal(DefaultAcmeStorageRegion)) + + Expect((&VmAcmeStorage{Endpoint: "http://x", PathStyle: lo.ToPtr(false)}).UsePathStyle()).To(BeFalse()) + Expect((&VmAcmeStorage{PathStyle: lo.ToPtr(true)}).UsePathStyle()).To(BeTrue()) + Expect((&VmAcmeStorage{Prefix: "certs"}).StoragePrefix()).To(Equal("certs")) + + // Absent on a single machine, which is the common case and must stay zero-config. + plain := ingressConfig() + Expect(plain.AcmeStorageEnabled()).To(BeFalse()) + // Present but with no bucket is not "enabled": validation refuses it, and the + // renderer must not emit a half-written block in the meantime. + plain.Ingress.AcmeStorage = &VmAcmeStorage{} + Expect(plain.AcmeStorageEnabled()).To(BeFalse()) +} + +// A cloud driver's own invariants are checked before the not-implemented refusal: +// an operator whose config is also wrong in shape should hear about the shape, so +// that the config is already right when the driver lands. + // TestValidateHostRoles is the role set of a byo pool, which is what replaced the // old "exactly one host" refusal (roadmap item sc-vm-multi-node-clusters, phase 1a). // @@ -807,3 +950,100 @@ func TestValidateRefusesUnverifiedProvider(t *testing.T) { Expect(err.Error()).To(ContainSubstring("`allowUnverifiedProvider` is not valid")) Expect(err.Error()).To(ContainSubstring("live-verified")) } + +// `ingress.originTls: false` and the ACME settings are alternatives, not a pair. Each +// refusal below replaces a belief an operator would otherwise keep: that the fleet +// still holds a certificate, or that a CA override still applies. Neither is visible +// until the thing in front of the machine goes away. +func TestValidateOriginTls(t *testing.T) { + RegisterTestingT(t) + + noTls := func(mutate func(*VmIngress)) *VmConfig { + cfg := byoConfig() + cfg.Ingress = &VmIngress{Enable: true, OriginTls: lo.ToPtr(false)} + mutate(cfg.Ingress) + return cfg + } + + // The whole point of the field: `email` stops being required, because there is no + // ACME account to name. + Expect(noTls(func(*VmIngress) {}).Validate()).ToNot(HaveOccurred()) + Expect(noTls(func(*VmIngress) {}).OriginTlsEnabled()).To(BeFalse()) + + for _, tc := range []struct { + name string + mutate func(*VmIngress) + errMatch string + }{ + { + name: "email", + mutate: func(i *VmIngress) { i.Email = "ops@example.com" }, + errMatch: "`ingress.email` is set together with `ingress.originTls: false`", + }, + { + name: "acmeCa", + mutate: func(i *VmIngress) { i.AcmeCa = "https://acme.example.com/directory" }, + errMatch: "`ingress.acmeCa` is set together with `ingress.originTls: false`", + }, + { + name: "acmeEab", + mutate: func(i *VmIngress) { + i.AcmeEabKeyID = "kid" + i.AcmeEabMacKey = "mac" + }, + errMatch: "`ingress.acmeEabKeyId` is set together with `ingress.originTls: false`", + }, + { + name: "acmeStorage", + mutate: func(i *VmIngress) { + i.AcmeStorage = &VmAcmeStorage{Bucket: "sc-acme", Region: "eu-central-1"} + }, + // Spelled out at length in the message because these are the two + // alternative front doors for a fleet and the operator has asked for both. + errMatch: "These are the two alternative front doors for a fleet, not a pair", + }, + { + // imageDelivery is checked BEFORE the origin-TLS branch, so a resource + // that is wrong in two ways still hears about the one that is wrong on + // every path. + name: "imageDelivery is still validated", + mutate: func(i *VmIngress) { i.ImageDelivery = "carrier-pigeon" }, + errMatch: "unknown `ingress.imageDelivery`", + }, + } { + t.Run(tc.name, func(t *testing.T) { + RegisterTestingT(t) + err := noTls(tc.mutate).Validate() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring(tc.errMatch)) + }) + } +} + +// Backwards compatibility, asserted rather than assumed: the operator's standing +// requirement is that nothing already deployed changes behaviour. Every way of NOT +// saying `originTls: false` has to read as "this machine terminates TLS", because that +// is what every vm resource written before the field did. +func TestOriginTlsDefaultsToOn(t *testing.T) { + RegisterTestingT(t) + + unset := byoConfig() + unset.Ingress = &VmIngress{Enable: true, Email: "ops@example.com"} + Expect(unset.OriginTlsEnabled()).To(BeTrue()) + + explicit := byoConfig() + explicit.Ingress = &VmIngress{Enable: true, Email: "ops@example.com", OriginTls: lo.ToPtr(true)} + Expect(explicit.OriginTlsEnabled()).To(BeTrue()) + Expect(explicit.Validate()).ToNot(HaveOccurred()) + + // No ingress at all: there is no Caddy to ask, and a caller that reads this must + // not take "false" for "an edge terminates" — it would render an http:// site for + // a machine that serves no sites. + none := byoConfig() + none.Ingress = nil + Expect(none.OriginTlsEnabled()).To(BeTrue()) + + disabled := byoConfig() + disabled.Ingress = &VmIngress{Enable: false, OriginTls: lo.ToPtr(false)} + Expect(disabled.OriginTlsEnabled()).To(BeTrue()) +}