Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,9 @@ cleanroom exec --expose 15432:5432 -- postgres

# Local HTTPS: usually https://buildkite.cleanroom.localhost:8143
cleanroom exec --expose-https buildkite:3000 -- mise exec -- npm run dev

# Or load HTTPS route names from expose.https in cleanroom.yaml
cleanroom exec --expose-https -- mise exec -- npm run dev
```

The installer starts the Cleanroom daemon. It does not install the macOS
Expand All @@ -106,6 +109,24 @@ sudo cleanroom dns install
listener is started by `--expose-https` and `cleanroom expose` while exposures
are active.

Configured HTTPS routes can enumerate full local hostnames or use a shared base:

```yaml
expose:
https:
base: "{sandbox_id}.cleanroom.localhost"
routes:
- port: 3000
hosts:
- "{base}"
- "*.{base}"
- "*.*.{base}"
```

`cleanroom dns install` manages `cleanroom.localhost`. Routes under another
local suffix, such as `{sandbox_id}.localhost`, also work when that suffix
resolves to the Cleanroom DNS listener.

Run Docker inside the microVM when the workload needs it:

```yaml
Expand Down
23 changes: 23 additions & 0 deletions docs/networking.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,29 @@ The URL is usually:
https://buildkite.cleanroom.localhost:8143
```

For projects that need several hostnames or wildcard labels, declare the routes
in `cleanroom.yaml` and pass `--expose-https` without a value:

```yaml
expose:
https:
base: "{sandbox_id}.cleanroom.localhost"
routes:
- port: 3000
hosts:
- "{base}"
- "*.{base}"
- "*.*.{base}"
```

```bash
cleanroom exec --expose-https -- npm run dev
```

`cleanroom dns install` manages `cleanroom.localhost`. Routes under another
local suffix, such as `{sandbox_id}.localhost`, also work when that suffix
resolves to the Cleanroom DNS listener.

For an existing sandbox, use:

```bash
Expand Down
11 changes: 5 additions & 6 deletions examples/multi-host-routing/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,14 +42,13 @@ cd examples/multi-host-routing
mise exec -- cleanroom policy validate
```

Start the example with three exact HTTPS hosts pointing at the same guest port:
Start the example with the HTTPS hosts declared in `cleanroom.yaml` pointing at
the same guest port:

```bash
mise exec -- cleanroom exec \
--backend darwin-vz \
--expose-https example:80 \
--expose-https example-app:80 \
--expose-https example-s3:80 \
--expose-https \
-- sh -lc 'cd /workspace/examples/multi-host-routing && sh ./start.sh'
```

Expand All @@ -65,8 +64,8 @@ If Cleanroom chooses a different HTTPS listener port, pass that port to

## What This Exercises

- exact route registration for hosts covered by the existing
`*.cleanroom.localhost` TLS certificate
- configured exact route registration for hosts covered by the local Cleanroom
certificate authority
- guest-side host-based virtual hosting in `nginx`
- preserved `Host`, `X-Forwarded-Host`, `X-Forwarded-Proto`,
`X-Forwarded-Port`, and `X-Forwarded-For`
Expand Down
9 changes: 9 additions & 0 deletions examples/multi-host-routing/cleanroom.yaml
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
version: 1

expose:
https:
routes:
- port: 80
hosts:
- example.cleanroom.localhost
- example-app.cleanroom.localhost
- example-s3.cleanroom.localhost

sandbox:
image:
ref: ghcr.io/buildkite/cleanroom-base/debian@sha256:28c3f638fabe1ed780f87b82cfb0c6dda2549c86b9e4edbe519e8250243411c5
Expand Down
2 changes: 2 additions & 0 deletions internal/cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ const (
type policyLoader interface {
LoadAndCompile(cwd string) (*policy.CompiledPolicy, string, error)
LoadRepository(cwd string) (policy.RepositoryConfig, string, error)
LoadExpose(cwd string) (policy.ExposeConfig, string, error)
}

type runtimeContext struct {
Expand Down Expand Up @@ -129,6 +130,7 @@ type hasExitCode interface {
}

func Run(args []string, version string) (runErr error) {
args = normalizeBareExposeHTTPSArgs(args)
runtimeCtx := &runtimeContext{
Stdout: os.Stdout,
Stderr: os.Stderr,
Expand Down
152 changes: 152 additions & 0 deletions internal/cli/configured_exposure.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
package cli

import (
"errors"
"fmt"
"strings"

"github.com/buildkite/cleanroom/internal/exposure"
cleanroomv1 "github.com/buildkite/cleanroom/internal/gen/cleanroom/v1"
"github.com/buildkite/cleanroom/internal/policy"
)

const configuredHTTPSPreflightSandboxID = "cr-preflight"

func normalizeBareExposeHTTPSArgs(args []string) []string {
if len(args) == 0 {
return nil
}
out := append([]string(nil), args...)
for i, arg := range out {
if arg == "--" {
break
}
Comment on lines +21 to +23
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Stop rewriting passthrough guest args as expose flags

normalizeBareExposeHTTPSArgs keeps scanning argv until a literal --, so it also rewrites --expose-https tokens that appear after a passthrough command has already started (for exec/console, which use passthrough:"partial"). In those cases --expose-https is a guest-command argument, not a Cleanroom flag, and this conversion silently changes the workload argv (for example cleanroom exec npm --expose-https becomes npm --expose-https=__cleanroom_configured_https__).

Useful? React with 👍 / 👎.

if arg != "--expose-https" {
continue
}
if i+1 >= len(out) || out[i+1] == "--" || strings.HasPrefix(out[i+1], "-") {
out[i] = "--expose-https=" + configuredHTTPSExposureSpec
Comment thread
lox marked this conversation as resolved.
}
}
return out
}

func resolveRequestedExposures(ctx *runtimeContext, cwd, sandboxID string, requested []*cleanroomv1.PortExposure) ([]*cleanroomv1.PortExposure, error) {
if !hasConfiguredHTTPSExposure(requested) {
return requested, nil
}
cfg, err := loadConfiguredHTTPSExposure(ctx, cwd)
if err != nil {
return nil, err
}
configured, err := expandConfiguredHTTPSExposures(cfg, sandboxID)
if err != nil {
return nil, err
}

resolved := make([]*cleanroomv1.PortExposure, 0, len(requested)+len(configured))
for _, req := range requested {
if isConfiguredHTTPSExposure(req) {
resolved = append(resolved, configured...)
continue
}
resolved = append(resolved, req)
}
return resolved, nil
}

func prevalidateConfiguredExposures(ctx *runtimeContext, cwd string, requested []*cleanroomv1.PortExposure) error {
if !hasConfiguredHTTPSExposure(requested) {
return nil
}
cfg, err := loadConfiguredHTTPSExposure(ctx, cwd)
if err != nil {
return err
}
_, err = expandConfiguredHTTPSExposures(cfg, configuredHTTPSPreflightSandboxID)
return err
}

func loadConfiguredHTTPSExposure(ctx *runtimeContext, cwd string) (policy.ExposeHTTPSConfig, error) {
if ctx == nil || ctx.Loader == nil {
return policy.ExposeHTTPSConfig{}, errors.New("configured --expose-https requires a policy loader")
}
cfg, _, err := ctx.Loader.LoadExpose(cwd)
if err != nil {
if errors.Is(err, policy.ErrPolicyNotFound) {
return policy.ExposeHTTPSConfig{}, errors.New("configured --expose-https requires expose.https in cleanroom.yaml")
}
return policy.ExposeHTTPSConfig{}, err
}
return cfg.HTTPS, nil
}

func hasConfiguredHTTPSExposure(requested []*cleanroomv1.PortExposure) bool {
for _, req := range requested {
if isConfiguredHTTPSExposure(req) {
return true
}
}
return false
}

func isConfiguredHTTPSExposure(req *cleanroomv1.PortExposure) bool {
return req != nil &&
strings.TrimSpace(req.GetProtocol()) == exposureProtocolHTTPS &&
strings.TrimSpace(req.GetName()) == configuredHTTPSExposureSpec &&
req.GetGuestPort() == 0
}

func expandConfiguredHTTPSExposures(cfg policy.ExposeHTTPSConfig, sandboxID string) ([]*cleanroomv1.PortExposure, error) {
sandboxID = strings.TrimSpace(strings.ToLower(sandboxID))
if sandboxID == "" {
return nil, errors.New("configured --expose-https requires a sandbox id")
}
if cfg.IsZero() {
return nil, errors.New("configured --expose-https requires expose.https in cleanroom.yaml")
}
base := strings.TrimSpace(strings.ToLower(cfg.Base))
if base != "" {
base = expandExposeTemplate(base, sandboxID, "")
base = strings.TrimSpace(strings.ToLower(base))
}
if strings.TrimSpace(cfg.Base) != "" && base == "" {
return nil, errors.New("expose.https.base expanded to an empty host")
}

exposures := make([]*cleanroomv1.PortExposure, 0)
for i, route := range cfg.Routes {
if route.Port < 1 || route.Port > 65535 {
return nil, fmt.Errorf("expose.https.routes[%d].port must be in range 1-65535", i)
}
if len(route.Hosts) == 0 {
return nil, fmt.Errorf("expose.https.routes[%d].hosts must include at least one host", i)
}
for j, host := range route.Hosts {
if strings.Contains(host, "{base}") && base == "" {
return nil, fmt.Errorf("expose.https.routes[%d].hosts[%d] uses {base} but expose.https.base is empty", i, j)
}
host = expandExposeTemplate(host, sandboxID, base)
host = strings.TrimSpace(strings.ToLower(host))
if host == "" {
return nil, fmt.Errorf("expose.https.routes[%d].hosts[%d] expanded to an empty host", i, j)
}
if err := exposure.ValidateHTTPSRouteName(host); err != nil {
return nil, fmt.Errorf("expose.https.routes[%d].hosts[%d] is invalid: %w", i, j, err)
}
exposures = append(exposures, &cleanroomv1.PortExposure{
Comment thread
lox marked this conversation as resolved.
Protocol: exposureProtocolHTTPS,
GuestPort: int32(route.Port),
Name: host,
})
}
}
return exposures, nil
}

func expandExposeTemplate(value, sandboxID, base string) string {
value = strings.ReplaceAll(value, "{sandbox_id}", sandboxID)
value = strings.ReplaceAll(value, "{container_id}", sandboxID)
value = strings.ReplaceAll(value, "{base}", base)
return value
}
Loading