From de73a22303dd8c8c3ad399cb4a7ef7e87589ade3 Mon Sep 17 00:00:00 2001 From: Tyler <53561637+im-tyler@users.noreply.github.com> Date: Wed, 23 Sep 2026 21:10:42 -0700 Subject: [PATCH] feat(preview): tailnet preview mode - --base-domain, --http-only, --allow-ip (corpus rev 6) Implements the teploy-cli half of Tyler's 2026-09-24 preview ruling (DELEGATED_DECISIONS_2026-09-23 section 10): previews served on plain HTTP under .sslip.io, gated to tailnet addresses. - preview deploy flags: --base-domain (hostname base instead of the app domain; hyphen or dot sslip form), --http-only (explicit http:// site address, no tls directive - Caddy never attempts ACME), --allow-ip (repeatable/comma; maps to caddy.Firewall.AllowIPs). Validated before connecting and again in Manager.Deploy before any mutation. - caddy.TLS gains HTTPOnly (wins over Cert/Key/Internal). - Mode persisted in the preview record as base_domain / http_only / allow_ips (all omitempty). Updates inherit each field unless the deploy overrides it (--http-only=false, --allow-ip "" clears), so a blue/green swap never re-enables HTTPS or drops the allowlist. Records without the fields behave exactly as before (app-domain host, automatic HTTPS, no gate). C06 identity (PreviewID/previewIDHex, route key, state path) and the blue/green machinery are unchanged. - preview list --json rows gain url (http:// iff http_only, else https://); domain kept; empty list still []. Text list and deploy output print the real scheme. - preview-exposure capability token (additive). - contracts rev 6 (additive): preview-state schema gains optional record/list-row fields on both eras with the url-scheme invariant; two list-row fixtures generated from the real encoder; version-handshake fixture regenerated for the new token. No MI bump. Tests: route written HTTP-only + gated, update inherits mode, field-by- field overrides, default/legacy records unchanged, State round-trip incl. pre-field and slug-era records, invalid input refused before mutation, flag parsing through the real cobra command, list JSON url scheme, caddy HTTPOnly rendering. Real Caddy (caddy:2-alpine via podman) accepted the rendered blocks: adapt OK with no tls app, :80 only; non-allowed source 403, allowed source proxied. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 14 + README.md | 13 + contracts/MANIFEST.md | 3 +- .../valid/canonical-list-row-tailnet.json | 20 ++ .../valid/canonical-list-row.json | 15 + .../fixtures/version-handshake/valid/mi2.json | 1 + contracts/schema/preview-state.schema.json | 19 +- docs/supported-workloads.md | 2 +- internal/caddy/caddy.go | 16 +- internal/caddy/caddy_test.go | 18 ++ internal/cli/contracts_golden_test.go | 46 +++ internal/cli/machineinterface.go | 6 + internal/cli/machineinterface_test.go | 3 +- internal/cli/preview.go | 107 ++++++- internal/cli/preview_exposure_test.go | 126 ++++++++ internal/preview/exposure_test.go | 271 ++++++++++++++++++ internal/preview/preview.go | 157 +++++++++- 17 files changed, 804 insertions(+), 33 deletions(-) create mode 100644 contracts/fixtures/preview-state/valid/canonical-list-row-tailnet.json create mode 100644 contracts/fixtures/preview-state/valid/canonical-list-row.json create mode 100644 internal/cli/preview_exposure_test.go create mode 100644 internal/preview/exposure_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index db6d326..fd89371 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,20 @@ All notable changes to teploy are documented here. Format follows [Keep a Change ### Added +- **Tailnet preview mode.** `teploy preview deploy` takes + `--base-domain ` (hostname base instead of the app domain, e.g. + `100-64-1-2.sslip.io`), `--http-only` (plain HTTP site, no ACME) and + `--allow-ip ` (repeatable; everything else gets 403). The mode + is stored in the preview record (`base_domain`, `http_only`, + `allow_ips`, all omitted for default previews) and inherited by later + deploys of the branch unless overridden (`--http-only=false`, + `--allow-ip ""`), so a blue/green update never silently re-enables + HTTPS or drops the allowlist. `preview list --json` rows gain `url` + (`http://` for HTTP-only, else `https://`); `domain` is unchanged. The + `preview-exposure` capability token is advertised. Preview identity + (`-p-`) and blue/green are unchanged; records without the + new fields behave exactly as before. + - **Plan/apply with drift invalidation (C05).** `teploy plan` now renders the full effect set — routing (domain/ingress/port/publishes), environment keys, storage volumes, resource limits, accessories — diff --git a/README.md b/README.md index 18d411f..c6b7f89 100644 --- a/README.md +++ b/README.md @@ -438,6 +438,19 @@ teploy preview deploy fix/login-500 --image app-build-abc1234 --ttl 24h Requires Teploy-managed Caddy: previews provision a `preview-.` route on demand, which an external ingress cannot do. +Tailnet-only previews skip the domain and the certificate: the hostname sits +under the target's Tailscale IP via sslip.io, Caddy serves it on plain HTTP, +and only tailnet addresses get through. + +``` +teploy preview deploy fix/login-500 --image app-build-abc1234 \ + --base-domain 100-64-1-2.sslip.io --http-only --allow-ip 100.64.0.0/10 +# http://preview-fix-login-500-.100-64-1-2.sslip.io +``` + +The mode is recorded with the preview, so redeploying the branch keeps it. +`preview list --json` rows carry a `url` with the scheme actually served. + ### Backups ``` teploy backup create # backup volumes to S3 diff --git a/contracts/MANIFEST.md b/contracts/MANIFEST.md index 54e8618..2234f12 100644 --- a/contracts/MANIFEST.md +++ b/contracts/MANIFEST.md @@ -10,6 +10,7 @@ Neutron/Nucleus dependency and a public mirror. | Corpus rev | Emitting CLI | Machine Interface | Notes | |---|---|---|---| +| 6 | main (tailnet preview mode, DELEGATED_DECISIONS §10) | 2 | Additive. preview-state schema gains optional record/list-row fields on both eras (`domain`, `url`, `base_domain`, `http_only`, `allow_ips`) with the invariant url scheme = `http://` iff `http_only` (else `https://`); two valid fixtures GENERATED from the real `preview list --json` row encoder (`previewListRows`, `contracts_golden_test.go`): canonical-list-row (default mode, no exposure keys, https url) and canonical-list-row-tailnet (base_domain + http_only + allow_ips, http url), each wrapped with the artifact's `era`/`app` classification keys (the wire row carries neither). The hand-authored identity fixtures (canonical, legacy, ambiguous) are unchanged. version-handshake gains the `preview-exposure` capability token (additive). No MI bump. | | 5 | main (X02 S2 tail: server-status fixtures + schema correction) | 2 | server-status-envelope fixtures landed (was "pending live capture"): valid x2 (full healthy observation, partial-caddy-unavailable — the class a target without a caddy container produces) + legacy pre-MI (machine_interface absent, the 42243e2-era shape). Encoder-derived: generated from the REAL `collectServerStatus` via a mock SSH executor (`contracts_golden_test.go`, TEPLOY_UPDATE_CONTRACTS) — synthetic values, real encoder and parse stages; the wire shape was verified against a live `server status --json` run before pinning. Defect fixed in the same commit: the schema had copied the appStatus root since its S2 draft (its own defect-fix commit 08cfb1b said so) and never described the actual serverStatusDTO wire format (server/host/uptime/load/memory/disks/docker/caddy) — rewritten to the real root with strict required-key coverage of the DTO's no-omitempty fields. Additive to consumers (a schema that matched nothing before now matches the wire); no MI bump. | | 4 | main (X02 S2 tail: server-list reshape) | 2 | **The MI 2 bump** (D8 non-additive): `server list --json` now emits the envelope `{machine_interface, servers[], observed_at}` carrying the per-server fields unchanged (name + id/host/user/role/tags/vpn_ip); the pre-reshape bare map-of-servers root is GONE on the wire and is pinned as the artifact's legacy class. New artifact server-list-envelope (schema + valid + legacy fixtures); version-handshake schema maximum 1→2 and its valid fixture renamed mi1→mi2 (app-list valid likewise — both envelopes now report MI 2). Capability tokens unchanged. Coordinated consumer: teploy-dash decodes both shapes during the transition (MaxSupportedMachineInterface 2). | | 3 (amended) | main (C05 plan-record corpus + defect fix) | 1 | C05 added the plan-record artifact + plan-apply token (see git history); amendment: server-status schema now carries its own $defs (its $refs never resolved), and app-list fixtures emit [] where the encoder emits [] (null fixtures failed schema + the real dash decode - found by dash's new contracts CI job, fixed here). | @@ -28,7 +29,7 @@ Neutron/Nucleus dependency and a public mirror. | error-envelope | yes | valid x2 + invalid code | teploy-cli | | release-record | yes | valid container | teploy-cli | | attempt-name | yes (pattern) | valid + invalid examples | teploy-cli | -| preview-state | yes (canonical/legacy) | valid + legacy + ambiguous | teploy-cli | +| preview-state | yes (canonical/legacy; optional record/list-row fields rev 6) | valid (hand-authored identity + 2 generated list rows) + legacy + ambiguous | teploy-cli | | observation-envelope | yes (§2.4 canonical, rev 2) | valid x4 (fresh, stale, unknown-unreachable, unreachable-last-known; dash encoder) | teploy-dash | | plan-record | yes | valid x2 (build unresolved-awaiting-build, prebuilt resolved-by-digest) + invalid tampered-id | teploy-cli | | operation-record | yes | pending S5/S6 (dash) | teploy-dash | diff --git a/contracts/fixtures/preview-state/valid/canonical-list-row-tailnet.json b/contracts/fixtures/preview-state/valid/canonical-list-row-tailnet.json new file mode 100644 index 0000000..c988476 --- /dev/null +++ b/contracts/fixtures/preview-state/valid/canonical-list-row-tailnet.json @@ -0,0 +1,20 @@ +{ + "allow_ips": [ + "100.64.0.0/10" + ], + "app": "myapp", + "base_domain": "100-64-1-2.sslip.io", + "branch": "feature/login", + "container": "myapp-preview-p-08e81639-abc1234", + "created_at": "2026-09-24T12:00:00Z", + "domain": "preview-feature-login-08e81639.100-64-1-2.sslip.io", + "era": "canonical", + "expires_at": "2026-09-27T12:00:00Z", + "http_only": true, + "id": "myapp-p-08e81639", + "image": "myapp-build-abc1234", + "port": 49200, + "repo": "github.com/example/myapp", + "route": "myapp-preview-p-08e81639", + "url": "http://preview-feature-login-08e81639.100-64-1-2.sslip.io" +} diff --git a/contracts/fixtures/preview-state/valid/canonical-list-row.json b/contracts/fixtures/preview-state/valid/canonical-list-row.json new file mode 100644 index 0000000..d4b546a --- /dev/null +++ b/contracts/fixtures/preview-state/valid/canonical-list-row.json @@ -0,0 +1,15 @@ +{ + "app": "myapp", + "branch": "feature/login", + "container": "myapp-preview-p-08e81639-abc1234", + "created_at": "2026-09-24T12:00:00Z", + "domain": "preview-feature-login-08e81639.myapp.com", + "era": "canonical", + "expires_at": "2026-09-27T12:00:00Z", + "id": "myapp-p-08e81639", + "image": "myapp-build-abc1234", + "port": 49200, + "repo": "github.com/example/myapp", + "route": "myapp-preview-p-08e81639", + "url": "https://preview-feature-login-08e81639.myapp.com" +} diff --git a/contracts/fixtures/version-handshake/valid/mi2.json b/contracts/fixtures/version-handshake/valid/mi2.json index 353b730..4292447 100644 --- a/contracts/fixtures/version-handshake/valid/mi2.json +++ b/contracts/fixtures/version-handshake/valid/mi2.json @@ -10,6 +10,7 @@ "plan-apply", "preview-blue-green", "preview-canonical-id", + "preview-exposure", "provenance-records", "readiness-receipts", "repair-debt", diff --git a/contracts/schema/preview-state.schema.json b/contracts/schema/preview-state.schema.json index cf421de..d417385 100644 --- a/contracts/schema/preview-state.schema.json +++ b/contracts/schema/preview-state.schema.json @@ -1,9 +1,25 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://teploy.github.io/contracts/schema/preview-state.schema.json", - "title": "preview identity eras (C06): canonical -p-, legacy slug, ambiguous", + "title": "preview identity eras (C06): canonical -p-, legacy slug, ambiguous; optional record/list-row fields (rev 6)", + "$defs": { + "recordFields": { + "$comment": "rev 6, additive: fields a preview record / `preview list --json` row may carry. Absent exposure fields = default preview (app-domain host, automatic HTTPS, no IP gate). url's scheme is the one the route serves: http:// iff http_only.", + "properties": { + "domain": {"type": "string", "minLength": 1}, + "url": {"type": "string", "pattern": "^https?://[^/]+$"}, + "base_domain": {"type": "string", "minLength": 1}, + "http_only": {"type": "boolean"}, + "allow_ips": {"type": "array", "items": {"type": "string", "minLength": 1}} + }, + "if": {"required": ["http_only"], "properties": {"http_only": {"const": true}}}, + "then": {"properties": {"url": {"pattern": "^http://"}}}, + "else": {"properties": {"url": {"pattern": "^https://"}}} + } + }, "oneOf": [ {"$comment": "canonical era", "type": "object", + "allOf": [{"$ref": "#/$defs/recordFields"}], "required": ["id", "era", "app", "branch"], "properties": { "id": {"type": "string", "pattern": "^[a-z0-9][a-z0-9-]*-p-[a-f0-9]{8}$"}, @@ -11,6 +27,7 @@ "app": {"type": "string"}, "branch": {"type": "string"}}}, {"$comment": "legacy slug-keyed era; readable, adoptable when unambiguous", "type": "object", + "allOf": [{"$ref": "#/$defs/recordFields"}], "required": ["id", "era", "app"], "properties": { "id": {"type": "string", "pattern": "^[a-z0-9][a-z0-9-]*-p-[a-z0-9][a-z0-9-]*$"}, diff --git a/docs/supported-workloads.md b/docs/supported-workloads.md index d3bb1c0..edd9b98 100644 --- a/docs/supported-workloads.md +++ b/docs/supported-workloads.md @@ -16,7 +16,7 @@ otherwise. | Static site (`type: static`) | Supported | rsync to the server, served by the managed Caddy. Requires Caddy ingress; `ingress: host`/`external` and `tls:` are rejected for static. | | Compose file as an importer (subset) | Supported (subset) | `docker-compose.yml` in the project dir imports when no `teploy.yml` exists. Every supplied field is preserved, translated, or rejected with a named error — see [migration.md](migration.md) for the classification summary. | | Templates (`teploy template install`) | Supported | One-command deploys of reviewed community apps (Postgres+Adminer, WordPress, Immich, ...). Catalog: `teploy template list`. | -| Preview environments (`teploy preview`) | Supported | Branch slugs on `preview-.` against a pre-built image (`teploy build`). Requires Teploy-managed Caddy. | +| Preview environments (`teploy preview`) | Supported | Branch slugs on `preview-.` against a pre-built image (`teploy build`). Requires Teploy-managed Caddy. Tailnet-only mode: `--base-domain .sslip.io --http-only --allow-ip 100.64.0.0/10`. | | Accessories (Postgres, Redis, MySQL, Mariaadb, Mongo, ClickHouse, Meilisearch, Elasticsearch, Memcached, RabbitMQ, NATS, or any standalone image) | Supported | Managed alongside the app with `--restart always`, volumes, ports, env. | | Multi-image stacks (several independently built services) | Refused | One image per app is the model. A Compose file whose service builds from a different context than the web service refuses at config load: `unsupported independent build in compose import: ... — teploy runs one image per app and cannot preserve a separately built service`. Model as separate teploy apps, or prebuilt images. | | Multiple web candidates in one Compose file | Refused | `ambiguous compose import: multiple non-accessory services publish ports (...)`. Remove ports from non-app services or write `teploy.yml`. | diff --git a/internal/caddy/caddy.go b/internal/caddy/caddy.go index 98a1e92..6a53f95 100644 --- a/internal/caddy/caddy.go +++ b/internal/caddy/caddy.go @@ -116,12 +116,21 @@ type TLS struct { // config.TLSConfig.Internal, which is where this is actually set from // teploy.yml. Internal bool + // HTTPOnly serves every host of the block on plain HTTP: each site + // address gets an explicit http:// scheme and no tls directive is + // rendered, so Caddy never attempts ACME for it. Takes precedence over + // Cert/Key/Internal. Used by tailnet previews (a 100.x address behind a + // wildcard DNS name can never complete a public ACME challenge). + HTTPOnly bool } // directive returns the indented `tls` line for a site block — `tls // internal` for Internal, `tls ` for a custom cert, or "" when // neither is configured (automatic HTTPS). func (t TLS) directive() string { + if t.HTTPOnly { + return "" + } if t.Internal { return "\ttls internal\n" } @@ -154,12 +163,13 @@ func IsPubliclyRoutable(host string) bool { // which case the operator has explicitly opted in and automatic-HTTPS // avoidance would just be wrong. See IsPubliclyRoutable for why this // matters: without it, Caddy attempts (and hangs on) a real ACME challenge -// for addresses that can never complete one. +// for addresses that can never complete one. tls.HTTPOnly forces the +// http:// scheme on every host, public or not. func siteAddresses(hosts []string, tls TLS) []string { - wantsTLS := tls.Internal || (tls.Cert != "" && tls.Key != "") + wantsTLS := !tls.HTTPOnly && (tls.Internal || (tls.Cert != "" && tls.Key != "")) out := make([]string, len(hosts)) for i, h := range hosts { - if !wantsTLS && !IsPubliclyRoutable(h) { + if tls.HTTPOnly || (!wantsTLS && !IsPubliclyRoutable(h)) { out[i] = "http://" + h } else { out[i] = h diff --git a/internal/caddy/caddy_test.go b/internal/caddy/caddy_test.go index 003033e..74a64e0 100644 --- a/internal/caddy/caddy_test.go +++ b/internal/caddy/caddy_test.go @@ -561,6 +561,24 @@ func TestReverseProxyBlock_CustomCertKeepsRealHost(t *testing.T) { } } +// TLS.HTTPOnly (tailnet previews): a PUBLIC hostname gets the explicit +// http:// scheme and no tls directive, so Caddy never attempts ACME for it; +// it wins over Internal/Cert, and composes with the firewall allowlist. +func TestReverseProxyBlock_HTTPOnly(t *testing.T) { + got := reverseProxyBlock([]string{"preview-main-563059ce.100.64.1.2.sslip.io"}, "myapp-preview-p-563059ce-v1", 3000, + TLS{HTTPOnly: true, Internal: true, Cert: "/c.crt", Key: "/c.key"}, "", nil, Firewall{AllowIPs: []string{"100.64.0.0/10"}}, Access{}) + want := "http://preview-main-563059ce.100.64.1.2.sslip.io {\n" + + "\t@teploy_fw_notallow not remote_ip 100.64.0.0/10\n" + + "\thandle @teploy_fw_notallow {\n\t\trespond 403\n\t}\n" + + "\thandle {\n\t\treverse_proxy myapp-preview-p-563059ce-v1:3000\n\t}\n}" + if got != want { + t.Errorf("reverseProxyBlock with HTTPOnly:\nwant: %q\ngot: %q", want, got) + } + if addrs := siteAddresses([]string{"a.example.com", "10.0.0.1"}, TLS{HTTPOnly: true}); addrs[0] != "http://a.example.com" || addrs[1] != "http://10.0.0.1" { + t.Errorf("siteAddresses with HTTPOnly = %v, want http:// on every host", addrs) + } +} + func TestMaintenanceBlock_NonPublicDomainGetsPlainHTTP(t *testing.T) { got := maintenanceBlock([]string{"192.168.1.114"}, SitePolicy{}) if !strings.HasPrefix(got, "http://192.168.1.114 {") { diff --git a/internal/cli/contracts_golden_test.go b/internal/cli/contracts_golden_test.go index 4ac7f9c..ae7beda 100644 --- a/internal/cli/contracts_golden_test.go +++ b/internal/cli/contracts_golden_test.go @@ -19,6 +19,7 @@ import ( "time" "github.com/useteploy/teploy/internal/config" + "github.com/useteploy/teploy/internal/preview" "github.com/useteploy/teploy/internal/releasemeta" "github.com/useteploy/teploy/internal/ssh" ) @@ -305,3 +306,48 @@ func TestContractsPlanRecordGolden(t *testing.T) { tampered.ConfigDigest = "0000000000000000000000000000000000000000000000000000000000000000" writeFixture(t, "plan-record/invalid/tampered-id.json", tampered) } + +// TestContractsPreviewStateListRowGolden drives the REAL `preview list +// --json` row encoder (previewListRows over preview.State) for a default +// and a tailnet-mode canonical preview (corpus rev 6). The row is wrapped +// with the artifact's era classification keys (era, app) — the wire row +// itself carries neither. The hand-authored identity fixtures (canonical, +// legacy, ambiguous) are unchanged. +func TestContractsPreviewStateListRowGolden(t *testing.T) { + created := time.Date(2026, 9, 24, 12, 0, 0, 0, time.UTC) + base := preview.State{ + ID: preview.PreviewID("myapp", "feature/login"), + Branch: "feature/login", + Repo: "github.com/example/myapp", + Route: "myapp-preview-p-08e81639", + Port: 49200, + Container: "myapp-preview-p-08e81639-abc1234", + Image: "myapp-build-abc1234", + CreatedAt: created, + ExpiresAt: created.Add(72 * time.Hour), + } + def := base + def.Domain = "preview-feature-login-08e81639.myapp.com" + tailnet := base + tailnet.Domain = "preview-feature-login-08e81639.100-64-1-2.sslip.io" + tailnet.BaseDomain = "100-64-1-2.sslip.io" + tailnet.HTTPOnly = true + tailnet.AllowIPs = []string{"100.64.0.0/10"} + + for name, s := range map[string]preview.State{ + "preview-state/valid/canonical-list-row.json": def, + "preview-state/valid/canonical-list-row-tailnet.json": tailnet, + } { + data, err := json.Marshal(previewListRows([]preview.State{s})[0]) + if err != nil { + t.Fatalf("marshal %s: %v", name, err) + } + var row map[string]any + if err := json.Unmarshal(data, &row); err != nil { + t.Fatalf("unmarshal %s: %v", name, err) + } + row["era"] = "canonical" + row["app"] = "myapp" + writeFixture(t, name, row) + } +} diff --git a/internal/cli/machineinterface.go b/internal/cli/machineinterface.go index f96b5b1..bf43f54 100644 --- a/internal/cli/machineinterface.go +++ b/internal/cli/machineinterface.go @@ -95,6 +95,11 @@ const ( // drifted — when anything moved since the plan (C05). Applied // releases carry provenance.plan_id. CapPlanApply = "plan-apply" + // `preview deploy --base-domain/--http-only/--allow-ip`: tailnet + // preview mode, persisted in the preview record (inherited by + // updates); `preview list --json` rows carry url with the served + // scheme (DELEGATED_DECISIONS §10). + CapPreviewExposure = "preview-exposure" ) // MachineCapabilities returns every capability token this build @@ -118,6 +123,7 @@ func MachineCapabilities() []string { CapServerStatusMachine, CapDoctorDiagnostics, CapPlanApply, + CapPreviewExposure, } sort.Strings(tokens) return tokens diff --git a/internal/cli/machineinterface_test.go b/internal/cli/machineinterface_test.go index 289af33..29f851b 100644 --- a/internal/cli/machineinterface_test.go +++ b/internal/cli/machineinterface_test.go @@ -92,6 +92,7 @@ func TestCapabilityTokenRegistry(t *testing.T) { "plan-apply", "preview-blue-green", "preview-canonical-id", + "preview-exposure", "provenance-records", "readiness-receipts", "repair-debt", @@ -125,7 +126,7 @@ func TestCapabilityTokenRegistry(t *testing.T) { CapHealthModes, CapProvenanceRecords, CapReadinessReceipts, CapPreviewCanonicalID, CapRepairDebt, CapPreviewBlueGreen, CapErrorEnvelope, CapAppListMachine, CapServerStatusMachine, - CapDoctorDiagnostics, CapPlanApply, + CapDoctorDiagnostics, CapPlanApply, CapPreviewExposure, } { if !member[token] { t.Fatalf("capability constant %q is not advertised", token) diff --git a/internal/cli/preview.go b/internal/cli/preview.go index 23ff0e4..afecce0 100644 --- a/internal/cli/preview.go +++ b/internal/cli/preview.go @@ -30,9 +30,29 @@ func newPreviewCmd(flags *Flags) *cobra.Command { return cmd } +// previewDeployOpts are the parsed `preview deploy` flags. The exposure +// fields are tri-state: a flag not given leaves its field unset (nil) so an +// update inherits the preview's recorded mode instead of resetting it. +type previewDeployOpts struct { + ttl string + image string + baseDomain string + httpOnly *bool + allowIPs []string +} + func newPreviewDeployCmd(flags *Flags) *cobra.Command { - var ttl string - var image string + return newPreviewDeployCmdWith(func(branch string, opts previewDeployOpts) error { + return runPreviewDeploy(flags, branch, opts) + }) +} + +// newPreviewDeployCmdWith builds the command around run (the test seam: +// flag parsing and validation are the real ones). +func newPreviewDeployCmdWith(run func(branch string, opts previewDeployOpts) error) *cobra.Command { + var opts previewDeployOpts + var httpOnly bool + var allowIPs []string cmd := &cobra.Command{ Use: "deploy ", @@ -46,20 +66,65 @@ touching production, which "teploy deploy" cannot do. Example: git checkout feat/new-landing teploy build --json # prints the image tag - teploy preview deploy feat-new-landing --ttl 24h --image `, + teploy preview deploy feat-new-landing --ttl 24h --image + +Tailnet-only preview (plain HTTP, reachable only from Tailscale addresses): + teploy preview deploy feat-new-landing --image \ + --base-domain 100-64-1-2.sslip.io --http-only --allow-ip 100.64.0.0/10 + +--base-domain, --http-only and --allow-ip are recorded with the preview; +a later deploy of the same branch keeps them unless it passes them again +(--http-only=false turns HTTP-only off, --allow-ip "" clears the list).`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - return runPreviewDeploy(flags, args[0], ttl, image) + if err := finishPreviewDeployOpts(cmd, &opts, httpOnly, allowIPs); err != nil { + return err + } + return run(args[0], opts) }, } - cmd.Flags().StringVar(&ttl, "ttl", "72h", "time-to-live before auto-expiry") - cmd.Flags().StringVar(&image, "image", "", "image to run (default: teploy.yml's image, else -build-)") + cmd.Flags().StringVar(&opts.ttl, "ttl", "72h", "time-to-live before auto-expiry") + cmd.Flags().StringVar(&opts.image, "image", "", "image to run (default: teploy.yml's image, else -build-)") + cmd.Flags().StringVar(&opts.baseDomain, "base-domain", "", "hostname base instead of the app domain (e.g. 100-64-1-2.sslip.io)") + cmd.Flags().BoolVar(&httpOnly, "http-only", false, "serve the preview over plain HTTP (no certificate)") + cmd.Flags().StringSliceVar(&allowIPs, "allow-ip", nil, "only this IP/CIDR may reach the preview (repeatable)") return cmd } -func runPreviewDeploy(flags *Flags, branch, ttlStr, image string) error { +// finishPreviewDeployOpts turns the raw exposure flags into opts, keeping +// "not given" distinct from "given as false/empty", and validates them +// before anything connects. +func finishPreviewDeployOpts(cmd *cobra.Command, opts *previewDeployOpts, httpOnly bool, allowIPs []string) error { + opts.baseDomain = strings.ToLower(strings.TrimSpace(opts.baseDomain)) + if cmd.Flags().Changed("base-domain") { + if err := preview.ValidateBaseDomain(opts.baseDomain); err != nil { + return err + } + } + opts.httpOnly = nil + if cmd.Flags().Changed("http-only") { + v := httpOnly + opts.httpOnly = &v + } + opts.allowIPs = nil + if cmd.Flags().Changed("allow-ip") { + opts.allowIPs = []string{} + for _, ip := range allowIPs { + if ip = strings.TrimSpace(ip); ip != "" { + opts.allowIPs = append(opts.allowIPs, ip) + } + } + if err := preview.ValidateAllowIPs(opts.allowIPs); err != nil { + return err + } + } + return nil +} + +func runPreviewDeploy(flags *Flags, branch string, opts previewDeployOpts) error { + ttlStr, image := opts.ttl, opts.image appCfg, err := config.LoadApp(".") if err != nil { return err @@ -136,6 +201,10 @@ func runPreviewDeploy(flags *Flags, branch, ttlStr, image string) error { Version: version, TTL: ttl, Repo: repo, + + BaseDomain: opts.baseDomain, + HTTPOnly: opts.httpOnly, + AllowIPs: opts.allowIPs, }) if n := buildNotifier(appCfg); n != nil { @@ -188,10 +257,7 @@ func runPreviewList(flags *Flags) error { return err } if flags.JSON { - if previews == nil { - previews = []preview.State{} - } - return json.NewEncoder(os.Stdout).Encode(previews) + return json.NewEncoder(os.Stdout).Encode(previewListRows(previews)) } if len(previews) == 0 { @@ -204,13 +270,30 @@ func runPreviewList(flags *Flags) error { if time.Now().UTC().After(p.ExpiresAt) { expired = " (expired)" } - fmt.Printf(" %s → https://%s%s\n", p.Branch, p.Domain, expired) + fmt.Printf(" %s → %s%s\n", p.Branch, p.URL(), expired) fmt.Printf(" Container: %s Port: %d Expires: %s\n", p.Container, p.Port, p.ExpiresAt.Format(time.RFC3339)) } return nil } +// previewListRow is one `preview list --json` row: the preview record +// plus its url, carrying the scheme the route actually serves (http:// for +// an HTTP-only preview). +type previewListRow struct { + preview.State + URL string `json:"url"` +} + +// previewListRows is the `preview list --json` encoder input: never null. +func previewListRows(previews []preview.State) []previewListRow { + rows := make([]previewListRow, 0, len(previews)) + for _, p := range previews { + rows = append(rows, previewListRow{State: p, URL: p.URL()}) + } + return rows +} + func newPreviewDestroyCmd(flags *Flags) *cobra.Command { return &cobra.Command{ Use: "destroy ", diff --git a/internal/cli/preview_exposure_test.go b/internal/cli/preview_exposure_test.go new file mode 100644 index 0000000..5277856 --- /dev/null +++ b/internal/cli/preview_exposure_test.go @@ -0,0 +1,126 @@ +package cli + +import ( + "encoding/json" + "reflect" + "strings" + "testing" + "time" + + "github.com/useteploy/teploy/internal/preview" +) + +// parsePreviewDeployFlags runs the real `preview deploy` command's flag +// parsing and option finishing, stopping before anything connects. +func parsePreviewDeployFlags(t *testing.T, args ...string) (previewDeployOpts, error) { + t.Helper() + var got previewDeployOpts + ran := false + cmd := newPreviewDeployCmdWith(func(branch string, opts previewDeployOpts) error { + ran, got = true, opts + return nil + }) + cmd.SetArgs(append([]string{"feature/login"}, args...)) + cmd.SilenceUsage, cmd.SilenceErrors = true, true + err := cmd.Execute() + if err == nil && !ran { + t.Fatal("command did not run") + } + return got, err +} + +func TestPreviewDeployFlags(t *testing.T) { + // No exposure flags: every exposure field unset, so an update inherits. + o, err := parsePreviewDeployFlags(t, "--image", "app-build-abc") + if err != nil { + t.Fatal(err) + } + if o.baseDomain != "" || o.httpOnly != nil || o.allowIPs != nil || o.image != "app-build-abc" || o.ttl != "72h" { + t.Errorf("defaults: %+v", o) + } + + // The tailnet invocation Ship sends. + o, err = parsePreviewDeployFlags(t, "--base-domain", "100-64-1-2.sslip.io", "--http-only", + "--allow-ip", "100.64.0.0/10", "--allow-ip", "fd7a:115c:a1e0::/48") + if err != nil { + t.Fatal(err) + } + if o.baseDomain != "100-64-1-2.sslip.io" || o.httpOnly == nil || !*o.httpOnly || + !reflect.DeepEqual(o.allowIPs, []string{"100.64.0.0/10", "fd7a:115c:a1e0::/48"}) { + t.Errorf("tailnet flags: %+v", o) + } + + // Dot form, uppercase normalized; comma list; explicit false; explicit + // empty allowlist (clears on update). + o, err = parsePreviewDeployFlags(t, "--base-domain", "100.64.1.2.SSLIP.io", "--http-only=false", + "--allow-ip", "100.64.0.0/10,10.0.0.1") + if err != nil { + t.Fatal(err) + } + if o.baseDomain != "100.64.1.2.sslip.io" || o.httpOnly == nil || *o.httpOnly || + !reflect.DeepEqual(o.allowIPs, []string{"100.64.0.0/10", "10.0.0.1"}) { + t.Errorf("dot form / false / comma list: %+v", o) + } + o, err = parsePreviewDeployFlags(t, "--allow-ip", "") + if err != nil { + t.Fatal(err) + } + if o.allowIPs == nil || len(o.allowIPs) != 0 { + t.Errorf(`--allow-ip "" must be an explicit empty list, got %#v`, o.allowIPs) + } + + for _, bad := range [][]string{ + {"--allow-ip", "100.64.0.0/33"}, + {"--allow-ip", "not-an-ip"}, + {"--base-domain", "localhost"}, + {"--base-domain", "bad domain.io"}, + {"--base-domain", ""}, + } { + if _, err := parsePreviewDeployFlags(t, bad...); err == nil { + t.Errorf("%v accepted", bad) + } + } +} + +// `preview list --json` rows carry url with the scheme the route serves, +// and keep domain; an empty list encodes as [], never null. +func TestPreviewListRowsURL(t *testing.T) { + exp := time.Date(2026, 9, 27, 0, 0, 0, 0, time.UTC) + rows := previewListRows([]preview.State{ + {ID: "myapp-p-08e81639", Branch: "feature/login", Domain: "preview-feature-login-08e81639.100-64-1-2.sslip.io", + HTTPOnly: true, AllowIPs: []string{"100.64.0.0/10"}, BaseDomain: "100-64-1-2.sslip.io", ExpiresAt: exp}, + {ID: "myapp-p-563059ce", Branch: "main", Domain: "preview-main-563059ce.myapp.com", ExpiresAt: exp}, + {Branch: "old", Domain: "preview-old.myapp.com", ExpiresAt: exp}, // legacy slug-era record + }) + data, err := json.Marshal(rows) + if err != nil { + t.Fatal(err) + } + var decoded []map[string]any + if err := json.Unmarshal(data, &decoded); err != nil { + t.Fatal(err) + } + want := []struct{ url, domain string }{ + {"http://preview-feature-login-08e81639.100-64-1-2.sslip.io", "preview-feature-login-08e81639.100-64-1-2.sslip.io"}, + {"https://preview-main-563059ce.myapp.com", "preview-main-563059ce.myapp.com"}, + {"https://preview-old.myapp.com", "preview-old.myapp.com"}, + } + for i, w := range want { + if decoded[i]["url"] != w.url || decoded[i]["domain"] != w.domain { + t.Errorf("row %d: url=%v domain=%v, want %s / %s", i, decoded[i]["url"], decoded[i]["domain"], w.url, w.domain) + } + } + if decoded[0]["http_only"] != true || decoded[0]["base_domain"] != "100-64-1-2.sslip.io" { + t.Errorf("tailnet row lost its mode fields: %v", decoded[0]) + } + for _, key := range []string{"http_only", "allow_ips", "base_domain"} { + if _, ok := decoded[1][key]; ok { + t.Errorf("default row must not carry %q: %v", key, decoded[1]) + } + } + + empty, _ := json.Marshal(previewListRows(nil)) + if strings.TrimSpace(string(empty)) != "[]" { + t.Errorf("empty list = %s, want []", empty) + } +} diff --git a/internal/preview/exposure_test.go b/internal/preview/exposure_test.go new file mode 100644 index 0000000..e2ea92d --- /dev/null +++ b/internal/preview/exposure_test.go @@ -0,0 +1,271 @@ +package preview + +// Tailnet preview mode (DELEGATED_DECISIONS_2026-09-23 §10): an explicit +// base domain, HTTP-only routes and an IP allowlist, persisted in the +// record so updates, list, prune and destroy keep them — and records +// without the fields behave exactly as before. + +import ( + "bytes" + "context" + "encoding/json" + "reflect" + "strings" + "testing" + "time" + + "github.com/useteploy/teploy/internal/ssh" +) + +const ( + tailnetBase = "100-64-1-2.sslip.io" + tailnetCIDR = "100.64.0.0/10" + tailnetDomain = "preview-feature-login-" + loginIDHex + "." + tailnetBase +) + +func boolPtr(b bool) *bool { return &b } + +func tailnetCfg(branch, version string) DeployConfig { + cfg := deployCfg(branch, version) + cfg.BaseDomain = tailnetBase + cfg.HTTPOnly = boolPtr(true) + cfg.AllowIPs = []string{tailnetCIDR} + return cfg +} + +func readState(t *testing.T, mock *ssh.MockExecutor, branch string) State { + t.Helper() + var s State + if err := json.Unmarshal(mock.Files[previewStatePath("myapp", branch)], &s); err != nil { + t.Fatalf("reading record: %v", err) + } + return s +} + +// managedBlock returns the Caddyfile region for a route key. +func managedBlock(t *testing.T, mock *ssh.MockExecutor, key string) string { + t.Helper() + caddyfile := string(mock.Files["/deployments/caddy/Caddyfile"]) + begin := strings.Index(caddyfile, "# TEPLOY BEGIN "+key+"\n") + end := strings.Index(caddyfile, "# TEPLOY END "+key+"\n") + if begin < 0 || end < begin { + t.Fatalf("no managed block for %s in:\n%s", key, caddyfile) + } + return caddyfile[begin:end] +} + +// The route is written HTTP-only (explicit http:// site address, no tls +// line) with the allowlist as its firewall, under the explicit base; the +// record carries the mode and the output names the http:// URL. +func TestDeploy_TailnetModeWritesHTTPOnlyGatedRoute(t *testing.T) { + mock := ssh.NewMockExecutor("1.2.3.4", previewDeployMocks()...) + var buf bytes.Buffer + mgr := NewManager(mock, &buf) + + mustDeploy(t, mgr, tailnetCfg(loginBranch, "v1")) + + block := managedBlock(t, mock, "myapp-preview-p-"+loginIDHex) + for _, want := range []string{ + "http://" + tailnetDomain + " {", + "@teploy_fw_notallow not remote_ip " + tailnetCIDR, + "reverse_proxy myapp-preview-p-" + loginIDHex + "-v1:80", + } { + if !strings.Contains(block, want) { + t.Errorf("route missing %q:\n%s", want, block) + } + } + if strings.Contains(block, "\ttls ") { + t.Errorf("HTTP-only route must not carry a tls directive:\n%s", block) + } + + s := readState(t, mock, loginBranch) + if s.Domain != tailnetDomain || s.BaseDomain != tailnetBase || !s.HTTPOnly || !reflect.DeepEqual(s.AllowIPs, []string{tailnetCIDR}) { + t.Errorf("record does not carry the mode: %+v", s) + } + // C06 identity untouched by the mode. + if s.ID != "myapp-p-"+loginIDHex || s.Route != "myapp-preview-p-"+loginIDHex { + t.Errorf("canonical identity changed: id=%q route=%q", s.ID, s.Route) + } + if !strings.Contains(buf.String(), "Preview deployed: http://"+tailnetDomain+"\n") { + t.Errorf("output must name the http:// URL, got:\n%s", buf.String()) + } +} + +// A blue/green update that does not repeat the flags keeps the mode: same +// hostname, still HTTP-only, still gated — never silently back to HTTPS or +// open. +func TestDeploy_UpdateInheritsTailnetMode(t *testing.T) { + mock := ssh.NewMockExecutor("1.2.3.4", previewDeployMocks()...) + var buf bytes.Buffer + mgr := NewManager(mock, &buf) + + mustDeploy(t, mgr, tailnetCfg(loginBranch, "v1")) + mustDeploy(t, mgr, deployCfg(loginBranch, "v2")) // no exposure overrides + + block := managedBlock(t, mock, "myapp-preview-p-"+loginIDHex) + if !strings.HasPrefix(strings.SplitN(block, "\n", 2)[1], "http://"+tailnetDomain+" {") { + t.Errorf("update re-enabled HTTPS or moved the hostname:\n%s", block) + } + if !strings.Contains(block, "not remote_ip "+tailnetCIDR) { + t.Errorf("update dropped the allowlist:\n%s", block) + } + if !strings.Contains(block, "-v2:80") { + t.Errorf("route does not point at the v2 candidate:\n%s", block) + } + s := readState(t, mock, loginBranch) + if s.Container != "myapp-preview-p-"+loginIDHex+"-v2" || s.Domain != tailnetDomain || + s.BaseDomain != tailnetBase || !s.HTTPOnly || !reflect.DeepEqual(s.AllowIPs, []string{tailnetCIDR}) { + t.Errorf("update record lost the mode: %+v", s) + } +} + +// Explicit overrides on an update win field by field: turning HTTP-only +// off keeps the recorded base and allowlist; an empty non-nil allowlist +// clears it. +func TestDeploy_UpdateOverridesFieldByField(t *testing.T) { + mock := ssh.NewMockExecutor("1.2.3.4", previewDeployMocks()...) + var buf bytes.Buffer + mgr := NewManager(mock, &buf) + + mustDeploy(t, mgr, tailnetCfg(loginBranch, "v1")) + cfg := deployCfg(loginBranch, "v2") + cfg.HTTPOnly = boolPtr(false) + mustDeploy(t, mgr, cfg) + + block := managedBlock(t, mock, "myapp-preview-p-"+loginIDHex) + if strings.Contains(block, "http://") || !strings.Contains(block, tailnetDomain+" {") { + t.Errorf("--http-only=false must serve the same host with automatic HTTPS:\n%s", block) + } + if !strings.Contains(block, "not remote_ip "+tailnetCIDR) { + t.Errorf("allowlist not inherited:\n%s", block) + } + + cfg = deployCfg(loginBranch, "v3") + cfg.AllowIPs = []string{} + mustDeploy(t, mgr, cfg) + block = managedBlock(t, mock, "myapp-preview-p-"+loginIDHex) + if strings.Contains(block, "remote_ip") { + t.Errorf("empty allowlist override must clear the gate:\n%s", block) + } + s := readState(t, mock, loginBranch) + if s.HTTPOnly || len(s.AllowIPs) != 0 || s.BaseDomain != tailnetBase { + t.Errorf("record after overrides: %+v", s) + } +} + +// Back-compat: a default deploy writes none of the new keys (the record is +// byte-shaped like before), and updating a record written before the +// fields existed keeps automatic HTTPS, no gate, and the app-domain host. +func TestDeploy_DefaultAndLegacyRecordsUnchanged(t *testing.T) { + mock := ssh.NewMockExecutor("1.2.3.4", previewDeployMocks()...) + var buf bytes.Buffer + mgr := NewManager(mock, &buf) + + mustDeploy(t, mgr, deployCfg(loginBranch, "v1")) + raw := string(mock.Files[previewStatePath("myapp", loginBranch)]) + for _, key := range []string{"base_domain", "http_only", "allow_ips"} { + if strings.Contains(raw, key) { + t.Errorf("default record must not carry %q:\n%s", key, raw) + } + } + if !strings.Contains(buf.String(), "Preview deployed: https://preview-feature-login-"+loginIDHex+".myapp.com\n") { + t.Errorf("default output must name the https:// URL, got:\n%s", buf.String()) + } + + // A pre-field canonical record on disk, then an update over it. + domain := "preview-feature-login-" + loginIDHex + ".myapp.com" + mock.Files[previewStatePath("myapp", loginBranch)] = []byte(canonicalRecordJSON("myapp", loginIDHex, loginBranch, + "myapp-preview-p-"+loginIDHex+"-v1", domain, time.Now().Add(time.Hour))) + mustDeploy(t, mgr, deployCfg(loginBranch, "v2")) + + block := managedBlock(t, mock, "myapp-preview-p-"+loginIDHex) + if !strings.HasPrefix(strings.SplitN(block, "\n", 2)[1], domain+" {") { + t.Errorf("legacy-record update must keep automatic HTTPS on the app-domain host:\n%s", block) + } + if strings.Contains(block, "remote_ip") { + t.Errorf("legacy-record update grew a gate:\n%s", block) + } + s := readState(t, mock, loginBranch) + if s.BaseDomain != "" || s.HTTPOnly || s.AllowIPs != nil || s.URL() != "https://"+domain { + t.Errorf("legacy-record update changed the mode: %+v", s) + } +} + +// State round-trip: the mode survives marshal/unmarshal; a record written +// before the fields existed decodes to the default mode and an https URL; +// the slug-era legacy record likewise. +func TestState_ExposureRoundTrip(t *testing.T) { + in := State{ + ID: "myapp-p-" + loginIDHex, Branch: loginBranch, Route: "myapp-preview-p-" + loginIDHex, + Domain: tailnetDomain, Port: 49200, Container: "c", Image: "i", + CreatedAt: time.Date(2026, 9, 24, 0, 0, 0, 0, time.UTC), ExpiresAt: time.Date(2026, 9, 27, 0, 0, 0, 0, time.UTC), + BaseDomain: tailnetBase, HTTPOnly: true, AllowIPs: []string{tailnetCIDR, "fd7a:115c:a1e0::/48"}, + } + data, err := json.Marshal(in) + if err != nil { + t.Fatal(err) + } + var out State + if err := json.Unmarshal(data, &out); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(in, out) { + t.Errorf("round-trip lost data:\nin: %+v\nout: %+v", in, out) + } + if out.URL() != "http://"+tailnetDomain { + t.Errorf("URL = %q, want http://", out.URL()) + } + + for name, raw := range map[string]string{ + "canonical-pre-field": canonicalRecordJSON("myapp", loginIDHex, loginBranch, "c", "preview-x.myapp.com", time.Now()), + "legacy-slug": legacyRecordJSON(loginBranch, "c"), + } { + var s State + if err := json.Unmarshal([]byte(raw), &s); err != nil { + t.Fatalf("%s: %v", name, err) + } + if s.BaseDomain != "" || s.HTTPOnly || s.AllowIPs != nil || !strings.HasPrefix(s.URL(), "https://") { + t.Errorf("%s decoded with a non-default mode: %+v url=%s", name, s, s.URL()) + } + } +} + +// Invalid overrides refuse before anything is touched on the server. +func TestDeploy_InvalidExposureRefusesBeforeMutation(t *testing.T) { + for name, mutate := range map[string]func(*DeployConfig){ + "allow-ip": func(c *DeployConfig) { c.AllowIPs = []string{"100.64.0.0/33"} }, + "allow-ip-junk": func(c *DeployConfig) { c.AllowIPs = []string{"1.2.3.4 }"} }, + "base-domain": func(c *DeployConfig) { c.BaseDomain = "Bad Domain {" }, + "no-base": func(c *DeployConfig) { c.Domain = "" }, + } { + t.Run(name, func(t *testing.T) { + mock := ssh.NewMockExecutor("1.2.3.4", previewDeployMocks()...) + mgr := NewManager(mock, &bytes.Buffer{}) + cfg := deployCfg(loginBranch, "v1") + mutate(&cfg) + if err := mgr.Deploy(context.Background(), cfg); err == nil { + t.Fatal("invalid exposure must fail the deploy") + } + for _, c := range mock.Calls { + if !strings.HasPrefix(c, "cat ") { + t.Errorf("mutated before validating: %q", c) + } + } + }) + } +} + +// Both sslip.io spellings are accepted as a base; a base without a dot or +// with uppercase/space is refused. +func TestValidateBaseDomain(t *testing.T) { + for _, ok := range []string{"100-64-1-2.sslip.io", "100.64.1.2.sslip.io", "myapp.com"} { + if err := ValidateBaseDomain(ok); err != nil { + t.Errorf("ValidateBaseDomain(%q): %v", ok, err) + } + } + for _, bad := range []string{"", "localhost", "Upper.sslip.io", "a..b", "-a.b", "a.b.", "a b.c", "http://a.b"} { + if err := ValidateBaseDomain(bad); err == nil { + t.Errorf("ValidateBaseDomain(%q) accepted", bad) + } + } +} diff --git a/internal/preview/preview.go b/internal/preview/preview.go index 6e98388..3eb6c85 100644 --- a/internal/preview/preview.go +++ b/internal/preview/preview.go @@ -50,6 +50,31 @@ type State struct { // `teploy preview prune` (and the deploy piggyback) destroy records // whose deadline has passed. ExpiresAt time.Time `json:"expires_at"` + + // Exposure mode (tailnet previews, DELEGATED_DECISIONS §10). All three + // are omitempty: a record without them is a default preview — hostname + // under the app's domain, automatic HTTPS, no IP gate — exactly the + // behavior of records written before these fields existed. Updates + // inherit them unless the deploy overrides them (resolveExposure), so + // a blue/green swap never silently re-enables HTTPS or drops the + // allowlist. + // + // BaseDomain is the explicit hostname base the preview was deployed + // under (--base-domain); empty means the app's domain. + BaseDomain string `json:"base_domain,omitempty"` + // HTTPOnly: the route serves plain HTTP (no certificate, no ACME). + HTTPOnly bool `json:"http_only,omitempty"` + // AllowIPs: when non-empty, only these IPs/CIDRs reach the route. + AllowIPs []string `json:"allow_ips,omitempty"` +} + +// URL is the preview's address with the scheme its route actually serves: +// http:// for an HTTP-only preview, https:// otherwise. +func (s State) URL() string { + if s.HTTPOnly { + return "http://" + s.Domain + } + return "https://" + s.Domain } // DeployConfig holds parameters for creating a preview. @@ -70,6 +95,95 @@ type DeployConfig struct { // (see State.Repo). Empty is allowed: the repo is provenance, not part // of the preview ID. Repo string + + // Exposure overrides. Each one left unset inherits the existing + // record's value on an update (and the default on a first deploy), so + // re-deploying a branch without repeating the flags keeps its mode. + // + // BaseDomain replaces Domain as the hostname base (e.g. + // "100-64-1-2.sslip.io"); "" = inherit, else Domain. + BaseDomain string + // HTTPOnly: nil = inherit, else the route is (not) plain HTTP. + HTTPOnly *bool + // AllowIPs: nil = inherit; non-nil replaces the allowlist (an empty + // non-nil slice clears it). Entries are IPs or CIDRs. + AllowIPs []string +} + +// exposure is the resolved route mode for one Deploy. +type exposure struct { + baseDomain string // base the hostname is built under + recorded string // State.BaseDomain ("" = the app domain) + httpOnly bool + allowIPs []string +} + +var validBaseDomain = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$`) + +// ValidateBaseDomain checks an explicit preview base domain: a lowercase +// multi-label DNS name, short enough that the preview label still fits. +func ValidateBaseDomain(base string) error { + if !validBaseDomain.MatchString(base) || len(base) > 253-64 { + return fmt.Errorf("invalid preview base domain %q: want a lowercase DNS name like 100-64-1-2.sslip.io", base) + } + return nil +} + +// ValidateAllowIPs checks that every entry is a bare IP address or a CIDR +// (the same rule teploy.yml's firewall.allow_ips enforces) — the values +// are rendered verbatim into the Caddyfile. +func ValidateAllowIPs(ips []string) error { + for _, ip := range ips { + if strings.Contains(ip, "/") { + if _, _, err := net.ParseCIDR(ip); err != nil { + return fmt.Errorf("invalid allow-ip %q: not a valid CIDR", ip) + } + } else if net.ParseIP(ip) == nil { + return fmt.Errorf("invalid allow-ip %q: not a valid IP address or CIDR", ip) + } + } + return nil +} + +// resolveExposure merges the deploy's overrides over the existing record's +// recorded mode (nil existing = first deploy: defaults). +func resolveExposure(cfg DeployConfig, existing *State) (exposure, error) { + var e exposure + switch { + case cfg.BaseDomain != "": + e.recorded = cfg.BaseDomain + case existing != nil: + e.recorded = existing.BaseDomain + } + if e.recorded != "" { + if err := ValidateBaseDomain(e.recorded); err != nil { + return e, err + } + e.baseDomain = e.recorded + } else { + e.baseDomain = cfg.Domain + } + if e.baseDomain == "" { + return e, fmt.Errorf("no preview base domain: the app has no domain and no --base-domain was given") + } + + if cfg.HTTPOnly != nil { + e.httpOnly = *cfg.HTTPOnly + } else if existing != nil { + e.httpOnly = existing.HTTPOnly + } + + src := cfg.AllowIPs + if src == nil && existing != nil { + src = existing.AllowIPs + } + if len(src) > 0 { + e.allowIPs = append([]string(nil), src...) + } + if err := ValidateAllowIPs(e.allowIPs); err != nil { + return e, err + } + return e, nil } // Manager handles preview environment lifecycle. @@ -324,6 +438,13 @@ func (m *Manager) Deploy(ctx context.Context, cfg DeployConfig) error { if err != nil { return err } + // The exposure mode is resolved (and validated) before anything is + // mutated, against the record as found: an update inherits whatever + // the deploy does not override. + exp, err := resolveExposure(cfg, existing) + if err != nil { + return err + } if existing != nil && existingPath == legacyPreviewStatePath(cfg.App, cfg.Branch) { adopted := *existing adopted.ID = PreviewID(cfg.App, cfg.Branch) @@ -340,7 +461,7 @@ func (m *Manager) Deploy(ctx context.Context, cfg DeployConfig) error { } idHex := previewIDHex(cfg.App, cfg.Branch) - domain := previewDomain(cfg.App, cfg.Branch, cfg.Domain) + domain := previewDomain(cfg.App, cfg.Branch, exp.baseDomain) // The process (container name component AND network alias) carries the // version: each candidate gets its own alias, so the stable route can // point at exactly one generation — a shared alias would round-robin @@ -351,6 +472,9 @@ func (m *Manager) Deploy(ctx context.Context, cfg DeployConfig) error { fmt.Fprintf(m.out, "Deploying preview for branch %q...\n", cfg.Branch) fmt.Fprintf(m.out, " Domain: %s\n", domain) + if len(exp.allowIPs) > 0 { + fmt.Fprintf(m.out, " Allow: %s\n", strings.Join(exp.allowIPs, " ")) + } // Ensure preview directory exists. if _, err := m.exec.Run(ctx, "mkdir -p "+previewDir(cfg.App)); err != nil { @@ -440,24 +564,29 @@ func (m *Manager) Deploy(ctx context.Context, cfg DeployConfig) error { // Switch the preview domain's route to the candidate. The route KEY // (and with it the canonical identity) is stable; only the upstream // container moves. Preview subdomains use Caddy automatic HTTPS (no - // custom cert). - if err := m.caddy.SetRoute(ctx, routeApp, domain, containerName, internalPort, caddy.TLS{}, "", nil, caddy.Firewall{}, caddy.Access{}); err != nil { + // custom cert) unless the preview is HTTP-only; an allowlist becomes + // the route's firewall. + if err := m.caddy.SetRoute(ctx, routeApp, domain, containerName, internalPort, + caddy.TLS{HTTPOnly: exp.httpOnly}, "", nil, caddy.Firewall{AllowIPs: exp.allowIPs}, caddy.Access{}); err != nil { return abortCandidate(err, "setting preview route — the previous preview is still serving") } // Write state. now := time.Now().UTC() state := State{ - ID: PreviewID(cfg.App, cfg.Branch), - Branch: cfg.Branch, - Repo: cfg.Repo, - Route: routeApp, - Domain: domain, - Port: port, - Container: containerName, - Image: cfg.Image, - CreatedAt: now, - ExpiresAt: now.Add(cfg.TTL), + ID: PreviewID(cfg.App, cfg.Branch), + Branch: cfg.Branch, + Repo: cfg.Repo, + Route: routeApp, + Domain: domain, + Port: port, + Container: containerName, + Image: cfg.Image, + CreatedAt: now, + ExpiresAt: now.Add(cfg.TTL), + BaseDomain: exp.recorded, + HTTPOnly: exp.httpOnly, + AllowIPs: exp.allowIPs, } if err := m.writeRecord(ctx, &state, previewStatePath(cfg.App, cfg.Branch)); err != nil { return fmt.Errorf("writing preview state: %w", err) @@ -476,7 +605,7 @@ func (m *Manager) Deploy(ctx context.Context, cfg DeployConfig) error { m.caddy.RemoveRoute(ctx, predecessorRoute) } - fmt.Fprintf(m.out, " Preview deployed: https://%s\n", domain) + fmt.Fprintf(m.out, " Preview deployed: %s\n", state.URL()) fmt.Fprintf(m.out, " Expires: %s\n", state.ExpiresAt.Format(time.RFC3339)) return nil }