From 00d1dd4209e13c9cc75167db9c5ef105d1838788 Mon Sep 17 00:00:00 2001 From: Cole Speelman Date: Sun, 16 Aug 2026 19:02:08 -0400 Subject: [PATCH] feat: publish Tend v0.2 preview source Publish the reviewed allowlisted snapshot whose exact binary completed maintenance deployment, rollback, and reactivation exercises for Gamertan and Sandwich Hime. Private-Source-Commit: 4d7094c8b7c61991bfb67b11fc1558724c874eb2 Private-Source-Tree: 54a2f74804f7acddf3755d7d4da5b97f5fc28381 AI-Assistance: OpenAI Codex assisted implementation, testing, security review, and release verification. Signed-off-by: Cole Speelman --- .gitignore | 2 + PUBLIC-SNAPSHOT.json | 2 +- PUBLIC-SNAPSHOT.sha256 | 2 +- README.md | 46 ++- RELEASE.md | 8 + SECURITY.md | 11 +- cmd/tend/main.go | 111 ++++++- docs/ARCHITECTURE.md | 33 +- docs/DOGFOOD_EVIDENCE.md | 79 +++++ docs/PUBLIC_SNAPSHOT.md | 5 + docs/SCHEMA_V2_MIGRATION.md | 28 ++ docs/THREAT_MODEL.md | 36 ++- docs/WALKTHROUGH.md | 57 ++++ examples/README.md | 23 +- examples/blue-green/example-site@.service | 3 +- examples/blue-green/tend.json | 7 +- examples/local/.env.example | 3 + examples/server/authorized_keys.example | 2 + .../server/environment/docs-site.env.example | 3 + .../environment/example-site.env.example | 2 + examples/server/example-singleton.service | 19 ++ examples/server/receive-policy.json | 16 + examples/server/services/docs-site.json | 24 ++ examples/server/services/example-site.json | 24 ++ examples/server/slots/example-site-blue.env | 2 + examples/server/slots/example-site-green.env | 2 + examples/server/tend-receive.sudoers | 3 + examples/singleton/tend.json | 8 +- internal/config/config.go | 70 ++-- internal/config/config_test.go | 22 +- internal/deploy/deploy.go | 41 ++- internal/deploy/deploy_test.go | 57 +++- internal/deploy/lock_linux_test.go | 30 ++ internal/deploy/operator.go | 22 +- internal/deploy/operator_test.go | 8 +- internal/serverpolicy/ownership_linux.go | 14 + internal/serverpolicy/ownership_other.go | 8 + internal/serverpolicy/policy.go | 300 ++++++++++++++++++ internal/serverpolicy/policy_test.go | 59 ++++ internal/transport/protocol.go | 123 +++++++ internal/transport/protocol_test.go | 81 +++++ internal/transport/push.go | 153 +++++++++ internal/transport/push_test.go | 81 +++++ internal/transport/receive.go | 48 +++ release/tend.json | 8 +- scripts/public-snapshot.allow | 23 ++ scripts/verify.sh | 5 + 47 files changed, 1590 insertions(+), 124 deletions(-) create mode 100644 docs/SCHEMA_V2_MIGRATION.md create mode 100644 docs/WALKTHROUGH.md create mode 100644 examples/local/.env.example create mode 100644 examples/server/authorized_keys.example create mode 100644 examples/server/environment/docs-site.env.example create mode 100644 examples/server/environment/example-site.env.example create mode 100644 examples/server/example-singleton.service create mode 100644 examples/server/receive-policy.json create mode 100644 examples/server/services/docs-site.json create mode 100644 examples/server/services/example-site.json create mode 100644 examples/server/slots/example-site-blue.env create mode 100644 examples/server/slots/example-site-green.env create mode 100644 examples/server/tend-receive.sudoers create mode 100644 internal/deploy/lock_linux_test.go create mode 100644 internal/serverpolicy/ownership_linux.go create mode 100644 internal/serverpolicy/ownership_other.go create mode 100644 internal/serverpolicy/policy.go create mode 100644 internal/serverpolicy/policy_test.go create mode 100644 internal/transport/protocol.go create mode 100644 internal/transport/protocol_test.go create mode 100644 internal/transport/push.go create mode 100644 internal/transport/push_test.go create mode 100644 internal/transport/receive.go diff --git a/.gitignore b/.gitignore index a1bf4c7..6ebbaa9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ /bin/ /dist/ *.tmp +.env.local +*.env.local diff --git a/PUBLIC-SNAPSHOT.json b/PUBLIC-SNAPSHOT.json index c8bf49c..2a35d86 100644 --- a/PUBLIC-SNAPSHOT.json +++ b/PUBLIC-SNAPSHOT.json @@ -1 +1 @@ -{"schema_version":1,"source_commit":"a72903c63e1753f9e6ffbf40453c0830bdfc05c5","source_tree":"295641e67eef5979da76746d8ae271249568263e","source_date_epoch":1786730082,"file_count":44} +{"schema_version":1,"source_commit":"4d7094c8b7c61991bfb67b11fc1558724c874eb2","source_tree":"54a2f74804f7acddf3755d7d4da5b97f5fc28381","source_date_epoch":1786920560,"file_count":68} diff --git a/PUBLIC-SNAPSHOT.sha256 b/PUBLIC-SNAPSHOT.sha256 index 740323e..ef89ca7 100644 --- a/PUBLIC-SNAPSHOT.sha256 +++ b/PUBLIC-SNAPSHOT.sha256 @@ -1 +1 @@ -b29d919cefddb06e0c47b453ffd55438944fc92da5732dbb6f98cb509a2c371e PUBLIC-SNAPSHOT.json +d30559d85177736bed9cbf4e1fa2e0703e3195d42390539efd098f091d64b983 PUBLIC-SNAPSHOT.json diff --git a/README.md b/README.md index 2f597dd..9065cad 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ Linux, systemd, and Caddy. It packages a clean pushed commit, records exact build provenance, activates a health-checked candidate, and keeps rollback state explicit. -The first preview deliberately supports two quiet deployment shapes: +The v0.2 development line supports two quiet deployment shapes: - Caddy-switched blue/green services. - A singleton service with an isolated transient candidate check. @@ -17,21 +17,43 @@ shell hooks. Application-specific data activation remains application-specific. ```text tend check --config tend.json -tend package --config tend.json --version v0.1.0-preview.2 --out dist -tend deploy --config /etc/example/tend.json --artifact FILE --sha256 HEX --approve-sha256 HEX -tend status --config /etc/example/tend.json -tend rollback --config /etc/example/tend.json -tend prune --config /etc/example/tend.json --keep 3 [--apply] +tend package --config tend.json --version v0.2.0-preview.1 --out dist +tend push --target tend-deploy@host --known-hosts FILE --service NAME --artifact FILE --sha256 HEX --approve-sha256 HEX +tend receive --policy /etc/tend/receive-policy.json +tend check-server --policy /etc/tend/receive-policy.json +tend deploy --config /etc/tend/services/example-site.json --artifact FILE --sha256 HEX --approve-sha256 HEX +tend status --config /etc/tend/services/example-site.json +tend rollback --config /etc/tend/services/example-site.json +tend prune --config /etc/tend/services/example-site.json --keep 3 [--apply] ``` -`deploy`, `rollback`, and applied pruning are target-host operations. Transfer -credentials and production configuration stay outside source repositories. -Dry-run and digest approval are intentional friction. +`push` transfers one approved artifact through a pinned OpenSSH connection. A +forced, no-shell receiver maps the service name to one root-owned configuration; +it accepts no remote path, environment value, URL, or shell fragment. Production +hosts receive binaries and evidence, never source or Go dependencies. -The first public preview has completed maintenance releases and explicit -rollback/reactivation for both Gamertan and the Sandwich Hime website using one -reviewed Tend candidate. See the dated +Schema 2 keeps all services under `/etc/tend/services/`, references a distinct +root-owned `0600` environment file for each service, and serializes activation +through `/run/lock/tend-deploy.lock`. Builds and transfers remain parallel; +only the short Caddy/service activation phase is host-wide. Tend is still a +single command, not a daemon. + +Dry-run and digest approval are intentional friction. See the +[schema-2 migration guide](docs/SCHEMA_V2_MIGRATION.md) and the +[two-service walkthrough](docs/WALKTHROUGH.md). + +Run `./scripts/verify.sh` on Linux. That required release lane exercises tests, +the race detector, vet, deterministic builds, schema-2 examples, and the +dependency-free module graph. Tend supports Linux hosts with systemd and Caddy; +WSL may be used as a Linux development environment, but native Windows is not a +supported execution, deployment, or release-gate platform. + +The v0.1 public preview and the restricted v0.2 implementation candidate each +completed maintenance releases and explicit rollback/reactivation for both +Gamertan and the Sandwich Hime website using one reviewed candidate. See the dated [dogfood evidence](docs/DOGFOOD_EVIDENCE.md) for exact scope and limitations. +The v0.2 preview will not be tagged until one identical binary has deployed and +rolled back both services through the restricted transport. The canonical public repository begins with a sanitized root snapshot rather than the private development history. diff --git a/RELEASE.md b/RELEASE.md index a70698e..25e141c 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -19,6 +19,14 @@ The release tag and attached candidate must be built from the final reviewed source commit. Documentation-only changes after the recorded campaign require one final identical-candidate maintenance pass before tagging. +`v0.2.0-preview.1` is a separate, additive release line. It requires schema 2, +the restricted `push`/`receive` transport, root-owned environment-file +references, host-wide activation serialization, and HTTPS public-origin smoke. +It may be tagged only after the exact same v0.2 binary successfully deploys, +rolls back, and reactivates both Gamertan and the Sandwich Hime website. EQL is +not part of this generic gate; its SQLite/catalog publication needs a dedicated +adapter rather than arbitrary hooks. + `v0.1.0-preview.1` is immutable but withdrawn: its source and module checksums are valid, while a fresh `go install` reports the development identity because the CLI did not yet adopt the tagged module version from Go build information. diff --git a/SECURITY.md b/SECURITY.md index 370e2dc..a746e01 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -4,11 +4,14 @@ Report suspected vulnerabilities privately to `security@sandwichhime.com`. Please include the affected Tend version, configuration shape, reproduction, and expected impact. Do not include production credentials or private logs. -Tend treats source repositories, release artifacts, configuration, handwritten -Caddy templates, and operators as trusted. It treats artifact paths, archives, +Tend treats reviewed source, pinned toolchains, root-owned server policy and +configuration, handwritten Caddy templates, and operators as trusted. It treats +requested service names, protocol frames, artifact paths and bytes, archives, filesystem state, process output, HTTP responses, and deployment targets as adversarial inputs. It never evaluates configuration as shell code. The preview is not a sandbox and does not make an untrusted repository safe to -build. Run `tend package` only for reviewed source. Production configuration -must be root-owned and kept outside repositories. +build. Run `tend package` only for reviewed source. Production configuration, +receive policy, host keys, identities, and `0600` environment files stay outside +repositories. A secret value appearing in a Tend report, artifact, state file, +or process argument is a security defect and should be reported. diff --git a/cmd/tend/main.go b/cmd/tend/main.go index 172bfbc..e31522d 100644 --- a/cmd/tend/main.go +++ b/cmd/tend/main.go @@ -19,6 +19,8 @@ import ( "gamertan.com/tend/internal/deploy" "gamertan.com/tend/internal/packager" "gamertan.com/tend/internal/process" + "gamertan.com/tend/internal/serverpolicy" + "gamertan.com/tend/internal/transport" "gamertan.com/tend/internal/version" ) @@ -41,6 +43,12 @@ func run() error { return packageCommand(ctx, os.Args[2:]) case "deploy": return deployCommand(ctx, os.Args[2:]) + case "push": + return pushCommand(ctx, os.Args[2:]) + case "receive": + return receiveCommand(ctx, os.Args[2:]) + case "check-server": + return checkServerCommand(os.Args[2:]) case "status": return statusCommand(ctx, os.Args[2:]) case "rollback": @@ -54,7 +62,7 @@ func run() error { } } func usage() error { - return errors.New("usage: tend [options]") + return errors.New("usage: tend [options]") } func checkCommand(args []string) error { @@ -123,7 +131,7 @@ func deployCommand(ctx context.Context, args []string) error { return err } if *activate { - if err := requireMutationAuthority(absolute); err != nil { + if err := requireMutationAuthority(absolute, cfg); err != nil { return err } } @@ -137,6 +145,90 @@ func deployCommand(ctx context.Context, args []string) error { } return writeJSON(report) } + +func pushCommand(ctx context.Context, args []string) error { + set := flag.NewFlagSet("push", flag.ContinueOnError) + set.SetOutput(os.Stderr) + target := set.String("target", "", "dedicated tend-deploy user and host") + port := set.Int("port", 22, "pinned SSH port") + knownHosts := set.String("known-hosts", "", "absolute pinned known-hosts file") + identity := set.String("identity", "", "optional absolute private key") + service := set.String("service", "", "allowed service name") + artifact := set.String("artifact", "", "immutable release archive") + sha := set.String("sha256", "", "expected artifact SHA-256") + approved := set.String("approve-sha256", "", "separately reviewed artifact SHA-256") + activate := set.Bool("activate", false, "activate after server validation") + if err := set.Parse(args); err != nil { + return err + } + if set.NArg() != 0 { + return errors.New("push accepts no positional arguments") + } + artifactAbs, err := filepath.Abs(*artifact) + if err != nil { + return err + } + result, err := transport.Push(ctx, transport.ExecSSHRunner{}, transport.PushOptions{Target: *target, Port: *port, KnownHosts: *knownHosts, Identity: *identity, Service: *service, Artifact: artifactAbs, SHA256: *sha, ApprovedSHA256: *approved, Activate: *activate}) + if err != nil { + return err + } + return writeJSON(result) +} + +func receiveCommand(ctx context.Context, args []string) error { + set := flag.NewFlagSet("receive", flag.ContinueOnError) + set.SetOutput(os.Stderr) + policyPath := set.String("policy", "/etc/tend/receive-policy.json", "root-owned receive policy") + if err := set.Parse(args); err != nil { + return err + } + if set.NArg() != 0 { + return errors.New("receive accepts no positional arguments") + } + if runtime.GOOS != "linux" || os.Geteuid() != 0 { + return errors.New("receive requires Linux root execution") + } + if os.Getenv("SSH_ORIGINAL_COMMAND") != transport.Protocol { + return errors.New("receive refused unexpected SSH command") + } + policy, err := serverpolicy.Load(*policyPath) + if err != nil { + return err + } + report, err := transport.Receive(ctx, os.Stdin, policy, newManager()) + if err != nil { + return err + } + return writeJSON(report) +} + +func checkServerCommand(args []string) error { + set := flag.NewFlagSet("check-server", flag.ContinueOnError) + set.SetOutput(os.Stderr) + policyPath := set.String("policy", "/etc/tend/receive-policy.json", "root-owned receive policy") + if err := set.Parse(args); err != nil { + return err + } + if set.NArg() != 0 { + return errors.New("check-server accepts no positional arguments") + } + if runtime.GOOS != "linux" || os.Geteuid() != 0 { + return errors.New("check-server requires Linux root execution") + } + policy, err := serverpolicy.Load(*policyPath) + if err != nil { + return err + } + services, err := policy.CheckFiles() + if err != nil { + return err + } + names := make([]string, 0, len(services)) + for _, service := range services { + names = append(names, service.Name) + } + return writeJSON(map[string]any{"valid": true, "schema_version": policy.SchemaVersion, "services": names}) +} func statusCommand(ctx context.Context, args []string) error { set := flag.NewFlagSet("status", flag.ContinueOnError) set.SetOutput(os.Stderr) @@ -175,7 +267,7 @@ func rollbackCommand(ctx context.Context, args []string) error { if !*activate { return writeJSON(map[string]any{"validated": true, "mutation": "none", "message": "repeat with --activate to switch to the recorded previous release"}) } - if err := requireMutationAuthority(absolute); err != nil { + if err := requireMutationAuthority(absolute, cfg); err != nil { return err } record, err := newManager().Rollback(ctx, cfg) @@ -201,7 +293,7 @@ func pruneCommand(args []string) error { return err } if *apply { - if err := requireMutationAuthority(absolute); err != nil { + if err := requireMutationAuthority(absolute, cfg); err != nil { return err } } @@ -225,21 +317,14 @@ func loadConfig(path string) (config.Config, string, error) { func newManager() deploy.Manager { return deploy.NewManager(deploy.SystemOperator{Runner: process.ExecRunner{}, Timeout: 5 * time.Second}) } -func requireMutationAuthority(configPath string) error { +func requireMutationAuthority(configPath string, cfg config.Config) error { if runtime.GOOS != "linux" { return errors.New("deployment mutations require Linux") } if os.Geteuid() != 0 { return errors.New("deployment mutations require root") } - info, err := os.Stat(configPath) - if err != nil { - return err - } - if info.Mode().Perm()&0o022 != 0 { - return errors.New("production configuration must not be group- or world-writable") - } - return nil + return serverpolicy.CheckConfig(configPath, cfg) } func writeJSON(value any) error { encoder := json.NewEncoder(os.Stdout) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index baddb6e..d30525d 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -17,12 +17,26 @@ requires byte-identical output. The resulting archive contains only: The external archive digest is the release identity used by deployment. +## Transport boundary + +`tend push` sends one bounded protocol frame to a dedicated account through a +pinned OpenSSH host key. It uses an argument vector, disables config files, +forwarding, PTYs, local commands, and proxy commands, and requests exactly +`tend-receive-v1`. The account's forced command invokes only the root-owned +receiver. The receiver's root-owned policy maps an allowlisted service name to +one exact configuration path and size ceiling. + +The target receives no source, repository credential, Go cache, dependency, or +arbitrary command. The artifact digest must equal both the produced digest and +the separately supplied approved digest before it is staged. + ## Target-host boundary -`tend deploy` runs on the Linux target. Transfer and SSH authentication stay -outside Tend v0.1; application-local transfer commands move the artifact and -the identical Tend binary to a root-controlled staging directory. Tend then -requires the expected digest to be repeated as a separately approved digest. +Schema-2 configurations live below `/etc/tend/services/`. Secrets live only in +separate `/etc/tend/environment/*.env` files that are root-owned, non-symlink, +and mode `0600`. Configurations contain the path, never the values. Candidate +and installed units read the same file; Tend overrides only the candidate's +loopback listen address. Dry-run validation extracts into a temporary directory and does not touch the release tree. Activation acquires the shared lock, installs a content-addressed @@ -39,8 +53,9 @@ Singleton mode starts the new release in a hardened transient systemd unit on a separate loopback address, probes it, stops the candidate, changes the current release pointer, and restarts the installed singleton unit. -Any failure before state persistence restores the previously observed Caddy -bytes and/or release pointers. Rollback is a separate explicit command over the -recorded state. It rechecks health and readiness, but deliberately does not -apply the current release's content markers to an older release whose routes -may differ. Pruning preserves both active and previous releases. +After the local post-activation probes, Tend also checks configured HTTPS public +origins. Any failure before state persistence restores the previously observed +Caddy bytes and/or release pointers. Rollback is a separate explicit command +over the recorded state. It rechecks local health/readiness and public reachability, +but deliberately does not apply a future release's content marker to an older +release whose routes may differ. Pruning preserves both active and previous releases. diff --git a/docs/DOGFOOD_EVIDENCE.md b/docs/DOGFOOD_EVIDENCE.md index 6cb56b9..c27139f 100644 --- a/docs/DOGFOOD_EVIDENCE.md +++ b/docs/DOGFOOD_EVIDENCE.md @@ -4,6 +4,85 @@ This is maintainer-run operational evidence, not an independent audit or a general reliability claim. It records the acceptance campaign completed on August 14, 2026 before Tend's first public preview. +## Restricted multi-service campaign — August 16, 2026 + +Tend's v0.2 implementation candidate completed the same maintenance contract +through the restricted transport for two independently configured services on +one Linux/systemd/Caddy host. + +### Assessed Tend implementation + +- Implementation source commit: + `840b77da708bbcd87a3203fb6a1f99b2984b8667`. +- Linux/amd64 candidate binary SHA-256: + `b3961315288871fa6085bf3b75c784a825a88f2bc3dd0694ad5d6c590eddf895`. +- Candidate archive SHA-256: + `948bad5271dca08b9445c387c5aea7f58f22add6b47b9071e1801a756aa71259`. +- Toolchain: Go 1.26.6, `CGO_ENABLED=0`, `-trimpath`. +- Trusted Gitea verification run 119 and release-candidate run 120 passed for + the exact implementation commit. CI and an independent, network-disabled + build each produced the same archive digest twice. +- The installed receiver accepted only a pinned Ed25519 host key, a dedicated + forced-command deployment key, the exact `tend-receive-v1` protocol, two + allowlisted service names, and separately repeated artifact digests. An + attempted arbitrary SSH command was refused. +- Production source, Go caches, repository credentials, secret values, remote + paths, and shell commands did not cross the transport boundary. + +Adding this evidence changes VCS build metadata but not executable logic. The +signed preview tag and attached release evidence therefore identify the final +evidence-bearing candidate and its required last maintenance pass. + +### Gamertan blue-green campaign + +- Application source commit: + `a7e54047d3dc11671824b6ecc8ed698a9dd04421`. +- Preview 27 artifact SHA-256: + `edadef3a97c089771e2b6bb7dadd58928762284878f76f23b112c61ae282f17f`. +- The artifact was built twice, byte-identically, in the pinned Go 1.26.6 + image with networking disabled and the audited cached module graph. +- The inactive slot passed health, readiness, page-marker, Caddy validation, + public-origin, Sandwich Hime mount, and EQL continuity checks before traffic + moved. +- Explicit rollback restored preview 26; its readiness and public boundary + passed; preview 27 was then reactivated through the recorded state. +- Sandwich Hime's release pointer and service remained unchanged throughout. + +### Sandwich Hime singleton campaign + +- Application source commit: + `435880c6751b773b6c5ee3ae6833d26e8eb7c0df`. +- Preview 30 artifact SHA-256: + `bee8d5bcde2c3c2e8bb96d5909a19889fc7f0be9390046c243fe20c9ff2ca44b`. +- The artifact was built twice, byte-identically, in the same pinned, + network-disabled Go 1.26.6 environment. +- A transient DynamicUser candidate passed health, readiness, tutorial marker, + canonical-origin, and Gamertan-mounted checks before the singleton pointer + and installed service changed. The candidate port was released afterward. +- Explicit rollback restored preview 28; its readiness and public boundary + passed; preview 30 was then reactivated through the recorded state. +- Both Gamertan slots, its selected Caddy upstream, and EQL remained healthy. + +### Findings resolved by dogfood + +The first restricted transfer stopped before artifact validation because +`sudo` removed `SSH_ORIGINAL_COMMAND`. The forced account still refused the +request; no service state changed. The 0BSD sudoers template now preserves only +that one server-supplied variable, while the receiver requires its exact +protocol value and rejects every other command. + +The first singleton candidate stopped before pointer mutation because the +shared environment file's live listen value overrode the candidate address. +The site remained on its former release and retained the same process. Tend now +rejects singleton shared environment files containing the configured listen +key; installed units own the non-secret live address and Tend supplies only the +transient candidate address. + +Final verification found both service states valid, every installed unit and +Caddy active with zero restart failures, the candidate port closed, no warning +or error entries after the successful campaign, and representative Gamertan, +Sandwich Hime, mounted, and EQL routes returning HTTP 200. + ## Assessed candidate - Source commit: `306d085e518cb4fe7b20a66d1e2ceb171e54ebdc`. diff --git a/docs/PUBLIC_SNAPSHOT.md b/docs/PUBLIC_SNAPSHOT.md index 36c50e9..a13e257 100644 --- a/docs/PUBLIC_SNAPSHOT.md +++ b/docs/PUBLIC_SNAPSHOT.md @@ -12,3 +12,8 @@ material markers. The resulting directory receives a new public root commit. Public Gitea is canonical for issues, contributions, and releases. GitHub is a read-only discovery copy of the same public tree. Tags belong only to canonical Gitea. + +The snapshot includes the program, security and architecture documentation, +copyable examples, release configuration, and local verification scripts. +Private workflows, runner configuration, repository credentials, and raw +operational evidence remain outside the public root. diff --git a/docs/SCHEMA_V2_MIGRATION.md b/docs/SCHEMA_V2_MIGRATION.md new file mode 100644 index 0000000..d765e66 --- /dev/null +++ b/docs/SCHEMA_V2_MIGRATION.md @@ -0,0 +1,28 @@ +# Schema 1 to schema 2 + +Schema 2 is intentionally not loaded as schema 1. Keep the installed v0.1 Tend +binary available until the first schema-2 activation and rollback have both +been exercised. + +1. Move each configuration to `/etc/tend/services/.json`. +2. Set `schema_version` to `2`. +3. Add `service.environment_file` below `/etc/tend/environment/`. +4. Create that file as a regular root-owned file with mode `0600`. Move secret + values out of JSON. Do not put the environment file in Git. For a singleton, + keep its `listen_env` key out of this shared file: set the live address in + the installed unit and let Tend supply only the candidate address. +5. Set every service's `deployment.lock_file` to + `/run/lock/tend-deploy.lock`. +6. Add one or more query-free HTTPS `deployment.public_smoke` checks. +7. Update installed systemd units to read the same environment file as the + transient candidate. +8. Install a root-owned `0600` receive policy mapping each service name to its + exact configuration and artifact-size ceiling. +9. Run `tend check-server` as root before accepting a transfer. +10. Validate, activate, rollback, and reactivate one service at a time. Confirm + unrelated services never restart. + +Tend does not discover `.env`, infer old values, rewrite a production file, or +silently migrate state. For local development, copy a committed `.env.example` +to an ignored `.env.local`, restrict its mode, and load it with the application's +own tooling. Tend never loads local dotenv files implicitly. diff --git a/docs/THREAT_MODEL.md b/docs/THREAT_MODEL.md index 20af618..aecad43 100644 --- a/docs/THREAT_MODEL.md +++ b/docs/THREAT_MODEL.md @@ -14,26 +14,36 @@ - Caddy configuration validates before reload. - An activation failure restores the previously observed state. - Active and previous releases survive pruning. +- The restricted receiver accepts one versioned bounded stream, one allowlisted + service name, and one explicitly approved digest. It accepts no remote path, + URL, environment value, or shell expression. +- OpenSSH host keys are pinned; forwarding, PTYs, proxy commands, local commands, + and user SSH configuration are disabled by the client invocation. +- Production secret values are absent from configuration, process arguments, + artifacts, manifests, reports, deployment state, and Tend logs. +- One host-wide lock serializes shared Caddy activation without stopping or + reconfiguring unrelated services. ## Trusted inputs -Reviewed source, the Go toolchain, root-owned `tend.json`, systemd unit files, -the Caddy handler template, the operator, and the target host's root account are -trusted. Packaging a hostile repository can execute hostile Go compiler hooks -or consume resources; Tend is not a source-code sandbox. +Reviewed source, the pinned Go toolchain, root-owned server policy and service +configuration, systemd unit files, the Caddy handler template, the operator, +and the target host's root account are trusted. Packaging a hostile repository +can execute hostile Go compiler hooks or consume resources; Tend is not a +source-code sandbox. ## Adversarial inputs -Artifact paths and bytes, archive metadata, stale or malformed state, HTTP -responses, subprocess failures, and filesystem objects at managed paths are -validated and fail closed. Process output is bounded. +Artifact paths and bytes, protocol frames, requested service names, archive +metadata, stale or malformed state, HTTP responses, subprocess failures, and +filesystem objects at managed paths are validated and fail closed. Process +output and receive fields are bounded. ## Non-goals and preview limits Tend does not defend against a concurrently malicious root user, a compromised -kernel/toolchain/systemd/Caddy installation, denial of service by the trusted -application, or secrets already exposed through service configuration. v0.1 -does not manage data migrations, databases, containers, Kubernetes, or -application-specific activation. Transfer remains an application-local, -reviewed step until a transport can be added without weakening host-key and -credential boundaries. +kernel/toolchain/systemd/Caddy/OpenSSH installation, denial of service by the +trusted application, or secrets an application itself exposes. It does not +manage data migrations, databases, containers, Kubernetes, or application-specific +activation. EQL therefore remains outside the generic deployment adapter until +its SQLite/catalog publication checkpoints can be modeled explicitly. diff --git a/docs/WALKTHROUGH.md b/docs/WALKTHROUGH.md new file mode 100644 index 0000000..c199cb0 --- /dev/null +++ b/docs/WALKTHROUGH.md @@ -0,0 +1,57 @@ +# Two-service build, approval, and deployment + +This walkthrough assumes one small Linux host running systemd and Caddy, two Go +services, and a trusted Gitea build runner. The host has the same root-owned Tend +binary at `/usr/local/bin/tend`; it does not need Git or Go. + +## Prepare the host once + +1. Create `/etc/tend/services`, `/etc/tend/environment`, and + `/var/lib/tend/incoming`. The incoming and environment directories are + root-owned mode `0700`. +2. Install one schema-2 file per service and one root-owned mode-`0600` + environment file per service. A singleton's shared environment file must + not define its configured listen key; its installed unit owns the live + address and Tend overrides only the transient candidate. +3. Install the receive policy, forced `authorized_keys` entry, and exact sudoers + rule from `examples/server/` after replacing every placeholder. The sudoers + fragment preserves only `SSH_ORIGINAL_COMMAND`; the root receiver requires + its exact protocol value and refuses every other requested command. +4. Pin the server host key in a dedicated client file. Do not accept a new key + interactively during deployment. +5. Run `sudo tend check-server` and inspect the allowlisted service names. + +## Build and approve + +Trusted CI checks a clean pushed commit, uses a pinned Go toolchain, packages +twice, and requires byte-identical archives. It publishes the archive, +`RELEASE.json`, `BUILDINFO.json`, SPDX SBOM, and SHA-256 evidence. + +The maintainer reads the candidate report and copies the exact approved digest +into the deployment command. Tend refuses a digest that is merely inferred from +the local file or differs from the produced value. + +```text +tend push --target tend-deploy@server.example \ + --known-hosts /secure/tend_known_hosts \ + --identity /secure/tend_deploy_ed25519 \ + --service example-site \ + --artifact /approved/example-site.tar.gz \ + --sha256 --approve-sha256 --activate +``` + +Repeat independently with `--service docs-site`. Both builds and transfers can +run concurrently. The host-wide lock serializes only activation because both +services share Caddy. Tend does not stop the other application. + +## Failure and recovery exercises + +- Change a candidate marker: activation must fail before state is stored. +- Make a Caddy template invalid: validation must fail and restore prior bytes. +- Make the public marker unavailable: Tend must restore the former slot/pointer. +- Run `tend rollback --activate` for one service and verify the other service's + units, pointers, and public origin did not change. +- Interrupt a transfer: no release becomes active and the incomplete incoming + file is removed when the receiver exits. + +After the soak, prune per service. Active and previous releases remain protected. diff --git a/examples/README.md b/examples/README.md index f27cb57..6ee412b 100644 --- a/examples/README.md +++ b/examples/README.md @@ -3,17 +3,28 @@ This subtree is licensed 0BSD so an operator can copy and adapt it without bringing the Tend program's AGPL license into an application configuration. -The blue/green example expects separately reviewed environment files: +The blue/green example expects one separately reviewed environment file that +the two installed slots and transient validation use consistently: ```text -# /etc/example-site/blue.env -EXAMPLE_LISTEN=127.0.0.1:8090 - -# /etc/example-site/green.env -EXAMPLE_LISTEN=127.0.0.1:8091 +# /etc/tend/environment/example-site.env +APP_SECRET=replace-on-server ``` +The blue/green systemd slot units then read the nonsecret listen address from +`/etc/tend/slots/example-site-blue.env` or `-green.env`; Tend overrides only +the isolated candidate address. Secret values never enter `tend.json`. + +The singleton example follows the same split: its shared root-only environment +file omits the configured listen key, the installed unit owns the live address, +and Tend supplies only the transient candidate address. This prevents a shared +environment file from overriding the isolated candidate port. + Production configuration belongs outside the source checkout, owned by root, and not group- or world-writable. The Caddy handler template is an entire imported handler fragment; the enclosing site, matchers, and routing precedence remain operator-owned. + +`server/` demonstrates the schema-2 receive policy, forced OpenSSH command, +restricted sudo entry, two independent service configurations, and secret-file +placement. The values are placeholders, not an installation script. diff --git a/examples/blue-green/example-site@.service b/examples/blue-green/example-site@.service index 792e370..5b5f4cf 100644 --- a/examples/blue-green/example-site@.service +++ b/examples/blue-green/example-site@.service @@ -6,7 +6,8 @@ After=network.target Type=simple DynamicUser=yes ExecStart=/opt/example-site/slots/%i/example-site -EnvironmentFile=/etc/example-site/%i.env +EnvironmentFile=/etc/tend/environment/example-site.env +EnvironmentFile=/etc/tend/slots/example-site-%i.env NoNewPrivileges=yes PrivateTmp=yes ProtectSystem=strict diff --git a/examples/blue-green/tend.json b/examples/blue-green/tend.json index d645271..b1b9a06 100644 --- a/examples/blue-green/tend.json +++ b/examples/blue-green/tend.json @@ -1,16 +1,17 @@ { - "schema_version": 1, - "service": { "name": "example-site", "allowed_host": "example.test" }, + "schema_version": 2, + "service": { "name": "example-site", "allowed_host": "example.test", "environment_file": "/etc/tend/environment/example-site.env" }, "build": { "package": "./cmd/site", "binary": "example-site", "branch": "main" }, "deployment": { "strategy": "blue_green", "root": "/opt/example-site", - "lock_file": "/run/lock/gamertan-deploy.lock", + "lock_file": "/run/lock/tend-deploy.lock", "state_file": "/opt/example-site/tend-state.json", "health_path": "/healthz", "readiness_path": "/readyz", "candidate_timeout_seconds": 30, "smoke": [{ "path": "/", "contains": "Example site" }], + "public_smoke": [{ "url": "https://example.test/", "contains": "Example site" }], "blue_green": { "caddy_config": "/etc/caddy/Caddyfile", "caddy_handler": "/etc/caddy/example-site-handler.caddy", diff --git a/examples/local/.env.example b/examples/local/.env.example new file mode 100644 index 0000000..54d24b5 --- /dev/null +++ b/examples/local/.env.example @@ -0,0 +1,3 @@ +# SPDX-License-Identifier: 0BSD +APP_MODE=development +APP_SECRET=replace-with-a-local-random-value diff --git a/examples/server/authorized_keys.example b/examples/server/authorized_keys.example new file mode 100644 index 0000000..f6fc8ce --- /dev/null +++ b/examples/server/authorized_keys.example @@ -0,0 +1,2 @@ +# SPDX-License-Identifier: 0BSD +restrict,command="sudo -n /usr/local/bin/tend receive --policy /etc/tend/receive-policy.json" ssh-ed25519 REPLACE_WITH_DEPLOY_KEY tend-deploy diff --git a/examples/server/environment/docs-site.env.example b/examples/server/environment/docs-site.env.example new file mode 100644 index 0000000..36714c6 --- /dev/null +++ b/examples/server/environment/docs-site.env.example @@ -0,0 +1,3 @@ +# SPDX-License-Identifier: 0BSD +DOCS_LISTEN=127.0.0.1:8102 +APP_SECRET=replace-on-server diff --git a/examples/server/environment/example-site.env.example b/examples/server/environment/example-site.env.example new file mode 100644 index 0000000..131fc81 --- /dev/null +++ b/examples/server/environment/example-site.env.example @@ -0,0 +1,2 @@ +# SPDX-License-Identifier: 0BSD +APP_SECRET=replace-on-server diff --git a/examples/server/example-singleton.service b/examples/server/example-singleton.service new file mode 100644 index 0000000..ab416ac --- /dev/null +++ b/examples/server/example-singleton.service @@ -0,0 +1,19 @@ +# SPDX-License-Identifier: 0BSD +[Unit] +Description=Example singleton site +After=network.target + +[Service] +Type=simple +DynamicUser=yes +ExecStart=/opt/example-site/current/example-site +EnvironmentFile=/etc/tend/environment/example-site.env +Environment=EXAMPLE_LISTEN=127.0.0.1:8092 +NoNewPrivileges=yes +PrivateTmp=yes +ProtectSystem=strict +ProtectHome=yes +Restart=on-failure + +[Install] +WantedBy=multi-user.target diff --git a/examples/server/receive-policy.json b/examples/server/receive-policy.json new file mode 100644 index 0000000..3340fae --- /dev/null +++ b/examples/server/receive-policy.json @@ -0,0 +1,16 @@ +{ + "schema_version": 1, + "config_root": "/etc/tend/services", + "incoming_root": "/var/lib/tend/incoming", + "shared_lock_file": "/run/lock/tend-deploy.lock", + "services": { + "docs-site": { + "config": "/etc/tend/services/docs-site.json", + "max_artifact_bytes": 134217728 + }, + "example-site": { + "config": "/etc/tend/services/example-site.json", + "max_artifact_bytes": 134217728 + } + } +} diff --git a/examples/server/services/docs-site.json b/examples/server/services/docs-site.json new file mode 100644 index 0000000..c396f31 --- /dev/null +++ b/examples/server/services/docs-site.json @@ -0,0 +1,24 @@ +{ + "schema_version": 2, + "service": { "name": "docs-site", "allowed_host": "docs.example.test", "environment_file": "/etc/tend/environment/docs-site.env" }, + "build": { "package": "./cmd/docs", "binary": "docs-site", "branch": "main" }, + "deployment": { + "strategy": "singleton_candidate", + "root": "/opt/docs-site", + "lock_file": "/run/lock/tend-deploy.lock", + "state_file": "/opt/docs-site/tend-state.json", + "health_path": "/healthz", + "readiness_path": "/readyz", + "candidate_timeout_seconds": 30, + "smoke": [{ "path": "/", "contains": "Documentation" }], + "public_smoke": [{ "url": "https://docs.example.test/", "contains": "Documentation" }], + "singleton": { + "unit": "docs-site.service", + "address": "127.0.0.1:8102", + "candidate_address": "127.0.0.1:18102", + "listen_env": "DOCS_LISTEN", + "current_link": "/opt/docs-site/current", + "previous_link": "/opt/docs-site/previous" + } + } +} diff --git a/examples/server/services/example-site.json b/examples/server/services/example-site.json new file mode 100644 index 0000000..2277047 --- /dev/null +++ b/examples/server/services/example-site.json @@ -0,0 +1,24 @@ +{ + "schema_version": 2, + "service": { "name": "example-site", "allowed_host": "example.test", "environment_file": "/etc/tend/environment/example-site.env" }, + "build": { "package": "./cmd/site", "binary": "example-site", "branch": "main" }, + "deployment": { + "strategy": "singleton_candidate", + "root": "/opt/example-site", + "lock_file": "/run/lock/tend-deploy.lock", + "state_file": "/opt/example-site/tend-state.json", + "health_path": "/healthz", + "readiness_path": "/readyz", + "candidate_timeout_seconds": 30, + "smoke": [{ "path": "/", "contains": "Example site" }], + "public_smoke": [{ "url": "https://example.test/", "contains": "Example site" }], + "singleton": { + "unit": "example-site.service", + "address": "127.0.0.1:8092", + "candidate_address": "127.0.0.1:18092", + "listen_env": "EXAMPLE_LISTEN", + "current_link": "/opt/example-site/current", + "previous_link": "/opt/example-site/previous" + } + } +} diff --git a/examples/server/slots/example-site-blue.env b/examples/server/slots/example-site-blue.env new file mode 100644 index 0000000..1da346a --- /dev/null +++ b/examples/server/slots/example-site-blue.env @@ -0,0 +1,2 @@ +# SPDX-License-Identifier: 0BSD +EXAMPLE_LISTEN=127.0.0.1:8090 diff --git a/examples/server/slots/example-site-green.env b/examples/server/slots/example-site-green.env new file mode 100644 index 0000000..57a9605 --- /dev/null +++ b/examples/server/slots/example-site-green.env @@ -0,0 +1,2 @@ +# SPDX-License-Identifier: 0BSD +EXAMPLE_LISTEN=127.0.0.1:8091 diff --git a/examples/server/tend-receive.sudoers b/examples/server/tend-receive.sudoers new file mode 100644 index 0000000..9bfd7fd --- /dev/null +++ b/examples/server/tend-receive.sudoers @@ -0,0 +1,3 @@ +# SPDX-License-Identifier: 0BSD +Defaults:tend-deploy env_keep += "SSH_ORIGINAL_COMMAND" +tend-deploy ALL=(root) NOPASSWD: /usr/local/bin/tend receive --policy /etc/tend/receive-policy.json diff --git a/examples/singleton/tend.json b/examples/singleton/tend.json index e8d22c9..2277047 100644 --- a/examples/singleton/tend.json +++ b/examples/singleton/tend.json @@ -1,22 +1,22 @@ { - "schema_version": 1, - "service": { "name": "example-site", "allowed_host": "example.test" }, + "schema_version": 2, + "service": { "name": "example-site", "allowed_host": "example.test", "environment_file": "/etc/tend/environment/example-site.env" }, "build": { "package": "./cmd/site", "binary": "example-site", "branch": "main" }, "deployment": { "strategy": "singleton_candidate", "root": "/opt/example-site", - "lock_file": "/run/lock/gamertan-deploy.lock", + "lock_file": "/run/lock/tend-deploy.lock", "state_file": "/opt/example-site/tend-state.json", "health_path": "/healthz", "readiness_path": "/readyz", "candidate_timeout_seconds": 30, "smoke": [{ "path": "/", "contains": "Example site" }], + "public_smoke": [{ "url": "https://example.test/", "contains": "Example site" }], "singleton": { "unit": "example-site.service", "address": "127.0.0.1:8092", "candidate_address": "127.0.0.1:18092", "listen_env": "EXAMPLE_LISTEN", - "environment": {}, "current_link": "/opt/example-site/current", "previous_link": "/opt/example-site/previous" } diff --git a/internal/config/config.go b/internal/config/config.go index dbcc3d9..cd4834d 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -9,13 +9,17 @@ import ( "fmt" "io" "net/netip" + "net/url" "os" "path/filepath" "regexp" "strings" ) -const SchemaVersion = 1 +const ( + SchemaVersion = 2 + SharedLockFile = "/run/lock/tend-deploy.lock" +) var ( namePattern = regexp.MustCompile(`^[a-z][a-z0-9-]{1,62}$`) @@ -33,8 +37,9 @@ type Config struct { } type Service struct { - Name string `json:"name"` - AllowedHost string `json:"allowed_host"` + Name string `json:"name"` + AllowedHost string `json:"allowed_host"` + EnvironmentFile string `json:"environment_file"` } type Build struct { @@ -47,16 +52,17 @@ type Build struct { } type Deployment struct { - Strategy string `json:"strategy"` - Root string `json:"root"` - LockFile string `json:"lock_file"` - StateFile string `json:"state_file"` - HealthPath string `json:"health_path"` - ReadinessPath string `json:"readiness_path"` - CandidateTimeoutSecs int `json:"candidate_timeout_seconds"` - Smoke []Smoke `json:"smoke"` - BlueGreen *BlueGreen `json:"blue_green,omitempty"` - Singleton *Singleton `json:"singleton,omitempty"` + Strategy string `json:"strategy"` + Root string `json:"root"` + LockFile string `json:"lock_file"` + StateFile string `json:"state_file"` + HealthPath string `json:"health_path"` + ReadinessPath string `json:"readiness_path"` + CandidateTimeoutSecs int `json:"candidate_timeout_seconds"` + Smoke []Smoke `json:"smoke"` + PublicSmoke []PublicSmoke `json:"public_smoke"` + BlueGreen *BlueGreen `json:"blue_green,omitempty"` + Singleton *Singleton `json:"singleton,omitempty"` } type Smoke struct { @@ -64,6 +70,11 @@ type Smoke struct { Contains string `json:"contains"` } +type PublicSmoke struct { + URL string `json:"url"` + Contains string `json:"contains"` +} + type BlueGreen struct { CaddyConfig string `json:"caddy_config"` CaddyHandler string `json:"caddy_handler"` @@ -80,13 +91,12 @@ type Slot struct { } type Singleton struct { - Unit string `json:"unit"` - Address string `json:"address"` - CandidateAddress string `json:"candidate_address"` - ListenEnv string `json:"listen_env"` - Environment map[string]string `json:"environment,omitempty"` - CurrentLink string `json:"current_link"` - PreviousLink string `json:"previous_link"` + Unit string `json:"unit"` + Address string `json:"address"` + CandidateAddress string `json:"candidate_address"` + ListenEnv string `json:"listen_env"` + CurrentLink string `json:"current_link"` + PreviousLink string `json:"previous_link"` } func Load(path string) (Config, error) { @@ -140,6 +150,12 @@ func (c Config) Validate() error { if c.Service.AllowedHost == "" || strings.ContainsAny(c.Service.AllowedHost, "/\\\x00\r\n\t ") { return errors.New("service.allowed_host is invalid") } + if err := safeAbsolute("service.environment_file", c.Service.EnvironmentFile); err != nil { + return err + } + if !within("/etc/tend/environment", c.Service.EnvironmentFile) { + return errors.New("service.environment_file must be below /etc/tend/environment") + } if !packagePattern.MatchString(c.Build.Package) || strings.Contains(c.Build.Package, "..") { return errors.New("build.package must be a local package without traversal") } @@ -185,6 +201,15 @@ func (c Config) Validate() error { return fmt.Errorf("deployment.smoke[%d] is invalid", i) } } + if len(d.PublicSmoke) == 0 || len(d.PublicSmoke) > 16 { + return errors.New("deployment.public_smoke must contain 1 to 16 checks") + } + for i, smoke := range d.PublicSmoke { + parsed, err := url.Parse(smoke.URL) + if err != nil || parsed.Scheme != "https" || parsed.Host == "" || parsed.User != nil || parsed.Fragment != "" || parsed.RawQuery != "" || parsed.Opaque != "" || smoke.Contains == "" || len(smoke.Contains) > 4096 || strings.ContainsRune(smoke.Contains, '\x00') { + return fmt.Errorf("deployment.public_smoke[%d] is invalid", i) + } + } switch d.Strategy { case "blue_green": if d.BlueGreen == nil || d.Singleton != nil { @@ -273,11 +298,6 @@ func validateSingleton(root string, s Singleton) error { if s.CurrentLink == s.PreviousLink { return errors.New("current and previous links must differ") } - for key, value := range s.Environment { - if !regexp.MustCompile(`^[A-Z][A-Z0-9_]{0,63}$`).MatchString(key) || strings.ContainsAny(value, "\x00\r\n") { - return fmt.Errorf("singleton environment entry %q is invalid", key) - } - } return nil } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 00350d5..12a6164 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -9,13 +9,14 @@ import ( func validConfig() Config { return Config{ - SchemaVersion: 1, - Service: Service{Name: "example-site", AllowedHost: "example.test"}, + SchemaVersion: 2, + Service: Service{Name: "example-site", AllowedHost: "example.test", EnvironmentFile: "/etc/tend/environment/example-site.env"}, Build: Build{Package: "./cmd/site", Binary: "example-site", Branch: "main"}, Deployment: Deployment{ - Strategy: "blue_green", Root: "/opt/example-site", LockFile: "/run/lock/example-site.lock", + Strategy: "blue_green", Root: "/opt/example-site", LockFile: SharedLockFile, StateFile: "/opt/example-site/state.json", HealthPath: "/healthz", ReadinessPath: "/readyz", CandidateTimeoutSecs: 30, Smoke: []Smoke{{Path: "/", Contains: "Example"}}, + PublicSmoke: []PublicSmoke{{URL: "https://example.test/", Contains: "Example"}}, BlueGreen: &BlueGreen{ CaddyConfig: "/etc/caddy/Caddyfile", CaddyHandler: "/etc/caddy/example.caddy", CaddyHandlerTemplate: "/etc/example/caddy.template", @@ -35,12 +36,15 @@ func TestValidateAcceptsBlueGreen(t *testing.T) { func TestValidateRejectsHostileValues(t *testing.T) { tests := map[string]func(*Config){ - "unknown strategy": func(c *Config) { c.Deployment.Strategy = "shell" }, - "nonloopback": func(c *Config) { c.Deployment.BlueGreen.Blue.Address = "203.0.113.7:80" }, - "root path": func(c *Config) { c.Deployment.Root = "/" }, - "traversal": func(c *Config) { c.Build.Package = "./cmd/../secret" }, - "shared slot": func(c *Config) { c.Deployment.BlueGreen.Green.Link = c.Deployment.BlueGreen.Blue.Link }, - "bad smoke": func(c *Config) { c.Deployment.Smoke[0].Path = "https://attacker.test/" }, + "unknown strategy": func(c *Config) { c.Deployment.Strategy = "shell" }, + "nonloopback": func(c *Config) { c.Deployment.BlueGreen.Blue.Address = "203.0.113.7:80" }, + "root path": func(c *Config) { c.Deployment.Root = "/" }, + "traversal": func(c *Config) { c.Build.Package = "./cmd/../secret" }, + "shared slot": func(c *Config) { c.Deployment.BlueGreen.Green.Link = c.Deployment.BlueGreen.Blue.Link }, + "bad smoke": func(c *Config) { c.Deployment.Smoke[0].Path = "https://attacker.test/" }, + "bad public smoke": func(c *Config) { c.Deployment.PublicSmoke[0].URL = "http://example.test/" }, + "public secret query": func(c *Config) { c.Deployment.PublicSmoke[0].URL = "https://example.test/?token=secret" }, + "environment sibling": func(c *Config) { c.Service.EnvironmentFile = "/etc/tend/environment-old/example.env" }, } for name, mutate := range tests { t.Run(name, func(t *testing.T) { diff --git a/internal/deploy/deploy.go b/internal/deploy/deploy.go index 115ab52..44d3f99 100644 --- a/internal/deploy/deploy.go +++ b/internal/deploy/deploy.go @@ -177,6 +177,9 @@ func (m Manager) deployBlueGreen(ctx context.Context, cfg config.Config, record if err = m.probeAll(ctx, cfg, slot.Address); err != nil { return fmt.Errorf("post-activation smoke failed: %w", err) } + if err = m.probePublic(ctx, cfg, true); err != nil { + return fmt.Errorf("public-origin smoke failed: %w", err) + } next := state.Record{SchemaVersion: 1, Strategy: cfg.Deployment.Strategy, ActiveSlot: inactive, ActiveRelease: release, PreviousSlot: record.ActiveSlot, PreviousRelease: record.ActiveRelease, UpdatedAt: m.Now().UTC().Format(time.RFC3339)} if err = state.Store(cfg.Deployment.StateFile, cfg.Deployment.Root, next); err != nil { return err @@ -187,10 +190,9 @@ func (m Manager) deployBlueGreen(ctx context.Context, cfg config.Config, record func (m Manager) deploySingleton(ctx context.Context, cfg config.Config, record state.Record, release string) (err error) { single := *cfg.Deployment.Singleton candidateUnit := cfg.Service.Name + "-tend-candidate.service" - env := copyMap(single.Environment) - env[single.ListenEnv] = single.CandidateAddress + env := map[string]string{single.ListenEnv: single.CandidateAddress} binary := filepath.Join(release, cfg.Build.Binary) - if err = m.Operator.StartCandidate(ctx, candidateUnit, binary, env); err != nil { + if err = m.Operator.StartCandidate(ctx, candidateUnit, binary, cfg.Service.EnvironmentFile, env); err != nil { return err } defer func() { @@ -238,6 +240,9 @@ func (m Manager) deploySingleton(ctx context.Context, cfg config.Config, record if err = m.probeAll(ctx, cfg, single.Address); err != nil { return fmt.Errorf("post-activation smoke failed: %w", err) } + if err = m.probePublic(ctx, cfg, true); err != nil { + return fmt.Errorf("public-origin smoke failed: %w", err) + } next := state.Record{SchemaVersion: 1, Strategy: cfg.Deployment.Strategy, ActiveSlot: "singleton", ActiveRelease: release, PreviousSlot: "singleton", PreviousRelease: record.ActiveRelease, UpdatedAt: m.Now().UTC().Format(time.RFC3339)} if err = state.Store(cfg.Deployment.StateFile, cfg.Deployment.Root, next); err != nil { return err @@ -313,6 +318,9 @@ func (m Manager) rollbackBlueGreen(ctx context.Context, cfg config.Config, recor if err = m.probeHealthReadiness(ctx, cfg, slot.Address); err != nil { return err } + if err = m.probePublic(ctx, cfg, false); err != nil { + return err + } next := state.Record{SchemaVersion: 1, Strategy: record.Strategy, ActiveSlot: record.PreviousSlot, ActiveRelease: record.PreviousRelease, PreviousSlot: record.ActiveSlot, PreviousRelease: record.ActiveRelease, UpdatedAt: m.Now().UTC().Format(time.RFC3339)} return state.Store(cfg.Deployment.StateFile, cfg.Deployment.Root, next) } @@ -337,6 +345,9 @@ func (m Manager) rollbackSingleton(ctx context.Context, cfg config.Config, recor if err = m.probeHealthReadiness(ctx, cfg, single.Address); err != nil { return err } + if err = m.probePublic(ctx, cfg, false); err != nil { + return err + } _ = replaceSymlink(single.PreviousLink, record.ActiveRelease) next := state.Record{SchemaVersion: 1, Strategy: record.Strategy, ActiveSlot: "singleton", ActiveRelease: record.PreviousRelease, PreviousSlot: "singleton", PreviousRelease: record.ActiveRelease, UpdatedAt: m.Now().UTC().Format(time.RFC3339)} return state.Store(cfg.Deployment.StateFile, cfg.Deployment.Root, next) @@ -435,6 +446,23 @@ func (m Manager) probeHealthReadiness(ctx context.Context, cfg config.Config, ad return m.probe(ctx, cfg, address, checks) } +func (m Manager) probePublic(ctx context.Context, cfg config.Config, checkMarkers bool) error { + timeout := time.Duration(cfg.Deployment.CandidateTimeoutSecs) * time.Second + for _, check := range cfg.Deployment.PublicSmoke { + attempt, cancel := context.WithTimeout(ctx, timeout) + contains := check.Contains + if !checkMarkers { + contains = "" + } + err := m.Operator.ProbeURL(attempt, check.URL, contains) + cancel() + if err != nil { + return err + } + } + return nil +} + func (m Manager) probe(ctx context.Context, cfg config.Config, address string, checks []config.Smoke) error { timeout := time.Duration(cfg.Deployment.CandidateTimeoutSecs) * time.Second for _, check := range checks { @@ -465,13 +493,6 @@ func slotConfig(bg config.BlueGreen, name string) config.Slot { } return bg.Green } -func copyMap(source map[string]string) map[string]string { - target := make(map[string]string, len(source)+1) - for k, v := range source { - target[k] = v - } - return target -} func resolveReleaseLink(root, link string) (string, error) { info, err := os.Lstat(link) if err != nil { diff --git a/internal/deploy/deploy_test.go b/internal/deploy/deploy_test.go index 5706f3e..6fc5013 100644 --- a/internal/deploy/deploy_test.go +++ b/internal/deploy/deploy_test.go @@ -22,6 +22,10 @@ type fakeOperator struct { active map[string]bool starts, stops, restarts []string probes []string + publicProbes []string + failPublic bool + candidateEnvironment map[string]string + candidateFile string } func (f *fakeOperator) Restart(_ context.Context, unit string) error { @@ -40,14 +44,58 @@ func (f *fakeOperator) Stop(_ context.Context, unit string) error { func (f *fakeOperator) IsActive(_ context.Context, unit string) (bool, error) { return f.active[unit], nil } -func (f *fakeOperator) StartCandidate(_ context.Context, unit, binary string, env map[string]string) error { - if !filepath.IsAbs(binary) || len(env) == 0 { +func (f *fakeOperator) StartCandidate(_ context.Context, unit, binary, environmentFile string, env map[string]string) error { + if !filepath.IsAbs(binary) || !filepath.IsAbs(environmentFile) || len(env) == 0 { return errors.New("bad candidate") } f.starts = append(f.starts, unit) + f.candidateFile = environmentFile + f.candidateEnvironment = make(map[string]string, len(env)) + for key, value := range env { + f.candidateEnvironment[key] = value + } f.active[unit] = true return nil } +func (f *fakeOperator) ProbeURL(_ context.Context, value, contains string) error { + f.publicProbes = append(f.publicProbes, value) + if f.failPublic { + return errors.New("injected public smoke failure") + } + if f.rejectMarkers && contains != "" { + return errors.New("unexpected future-release smoke marker") + } + return nil +} + +func TestPublicSmokeFailureRestoresBlueGreenHandlerAndSlot(t *testing.T) { + cfg, old, fresh := baseConfig(t, "blue_green") + handler := filepath.Join(cfg.Deployment.Root, "handler.caddy") + template := filepath.Join(cfg.Deployment.Root, "handler.template") + original := []byte("reverse_proxy 127.0.0.1:8090\n") + _ = os.WriteFile(handler, original, 0o644) + _ = os.WriteFile(template, []byte("reverse_proxy {{UPSTREAM}}\n"), 0o644) + blue := filepath.Join(cfg.Deployment.Root, "slots", "blue") + green := filepath.Join(cfg.Deployment.Root, "slots", "green") + _ = replaceSymlink(blue, old) + _ = replaceSymlink(green, old) + cfg.Deployment.BlueGreen = &config.BlueGreen{CaddyConfig: filepath.Join(cfg.Deployment.Root, "Caddyfile"), CaddyHandler: handler, CaddyHandlerTemplate: template, BootstrapActive: "blue", Blue: config.Slot{Unit: "example-blue.service", Address: "127.0.0.1:8090", Link: blue}, Green: config.Slot{Unit: "example-green.service", Address: "127.0.0.1:8091", Link: green}} + operator := &fakeOperator{active: map[string]bool{}, failPublic: true} + if _, err := manager(operator, fresh).Deploy(context.Background(), cfg, Request{Activate: true}); err == nil { + t.Fatal("expected public smoke failure") + } + body, _ := os.ReadFile(handler) + if string(body) != string(original) { + t.Fatalf("handler not restored: %q", body) + } + target, err := resolveReleaseLink(cfg.Deployment.Root, green) + if err != nil || target != old { + t.Fatalf("green=%q err=%v", target, err) + } + if _, err = os.Stat(cfg.Deployment.StateFile); !os.IsNotExist(err) { + t.Fatal("failed public smoke wrote state") + } +} func (f *fakeOperator) ValidateCaddy(context.Context, string) error { return nil } func (f *fakeOperator) ReloadCaddy(context.Context) error { if f.failReload { @@ -79,7 +127,7 @@ func baseConfig(t *testing.T, strategy string) (config.Config, string, string) { t.Fatal(err) } } - cfg := config.Config{SchemaVersion: 1, Service: config.Service{Name: "example-site", AllowedHost: "example.test"}, Build: config.Build{Package: "./cmd/site", Binary: "app", Branch: "main"}, Deployment: config.Deployment{Strategy: strategy, Root: root, LockFile: filepath.Join(root, "deploy.lock"), StateFile: filepath.Join(root, "state.json"), HealthPath: "/healthz", ReadinessPath: "/readyz", CandidateTimeoutSecs: 2, Smoke: []config.Smoke{{Path: "/", Contains: "Example"}}}} + cfg := config.Config{SchemaVersion: 2, Service: config.Service{Name: "example-site", AllowedHost: "example.test", EnvironmentFile: "/etc/tend/environment/example-site.env"}, Build: config.Build{Package: "./cmd/site", Binary: "app", Branch: "main"}, Deployment: config.Deployment{Strategy: strategy, Root: root, LockFile: filepath.Join(root, "deploy.lock"), StateFile: filepath.Join(root, "state.json"), HealthPath: "/healthz", ReadinessPath: "/readyz", CandidateTimeoutSecs: 2, Smoke: []config.Smoke{{Path: "/", Contains: "Example"}}, PublicSmoke: []config.PublicSmoke{{URL: "https://example.test/", Contains: "Example"}}}} return cfg, old, fresh } func manager(operator Operator, fresh string) Manager { @@ -210,4 +258,7 @@ func TestStatePersistsOnlyAfterSuccessfulActivation(t *testing.T) { if record.ActiveRelease != fresh || record.PreviousRelease != old { t.Fatalf("state=%+v", record) } + if operator.candidateFile != cfg.Service.EnvironmentFile || len(operator.candidateEnvironment) != 1 || operator.candidateEnvironment["EXAMPLE_LISTEN"] != "127.0.0.1:18092" { + t.Fatalf("candidate file=%q environment=%#v", operator.candidateFile, operator.candidateEnvironment) + } } diff --git a/internal/deploy/lock_linux_test.go b/internal/deploy/lock_linux_test.go new file mode 100644 index 0000000..37657b6 --- /dev/null +++ b/internal/deploy/lock_linux_test.go @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: AGPL-3.0-only +//go:build linux + +package deploy + +import ( + "path/filepath" + "testing" +) + +func TestHostWideLockSerializesIndependentServices(t *testing.T) { + path := filepath.Join(t.TempDir(), "tend-deploy.lock") + first, err := acquireLock(path) + if err != nil { + t.Fatal(err) + } + defer first.Close() + if second, err := acquireLock(path); err == nil { + _ = second.Close() + t.Fatal("second service acquired the shared activation lock") + } + if err = first.Close(); err != nil { + t.Fatal(err) + } + third, err := acquireLock(path) + if err != nil { + t.Fatal(err) + } + _ = third.Close() +} diff --git a/internal/deploy/operator.go b/internal/deploy/operator.go index 0ffb8e4..1e81a0d 100644 --- a/internal/deploy/operator.go +++ b/internal/deploy/operator.go @@ -20,10 +20,11 @@ type Operator interface { Restart(context.Context, string) error Stop(context.Context, string) error IsActive(context.Context, string) (bool, error) - StartCandidate(context.Context, string, string, map[string]string) error + StartCandidate(context.Context, string, string, string, map[string]string) error ValidateCaddy(context.Context, string) error ReloadCaddy(context.Context) error Probe(context.Context, string, string, string, string) error + ProbeURL(context.Context, string, string) error } type SystemOperator struct { @@ -49,7 +50,7 @@ func (o SystemOperator) IsActive(ctx context.Context, unit string) (bool, error) } return strings.TrimSpace(string(out)) == "active", nil } -func (o SystemOperator) StartCandidate(ctx context.Context, unit, binary string, env map[string]string) error { +func (o SystemOperator) StartCandidate(ctx context.Context, unit, binary, environmentFile string, env map[string]string) error { args := []string{ "--unit", unit, "--collect", "--property=DynamicUser=yes", "--property=NoNewPrivileges=yes", @@ -63,6 +64,7 @@ func (o SystemOperator) StartCandidate(ctx context.Context, unit, binary string, "--property=RestrictSUIDSGID=yes", "--property=LockPersonality=yes", "--property=MemoryDenyWriteExecute=yes", "--property=CapabilityBoundingSet=", "--property=AmbientCapabilities=", + "--property=EnvironmentFile=" + environmentFile, } keys := make([]string, 0, len(env)) for key := range env { @@ -86,11 +88,23 @@ func (o SystemOperator) ReloadCaddy(ctx context.Context) error { } func (o SystemOperator) Probe(ctx context.Context, address, host, path, contains string) error { u := url.URL{Scheme: "http", Host: address, Path: path} - req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) + return o.probeRequest(ctx, u.String(), host, contains) +} +func (o SystemOperator) ProbeURL(ctx context.Context, value, contains string) error { + u, err := url.Parse(value) + if err != nil || u.Scheme != "https" || u.Host == "" || u.User != nil || u.Fragment != "" { + return errors.New("public probe URL is invalid") + } + return o.probeRequest(ctx, u.String(), "", contains) +} +func (o SystemOperator) probeRequest(ctx context.Context, value, host, contains string) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, value, nil) if err != nil { return err } - req.Host = host + if host != "" { + req.Host = host + } client := &http.Client{Timeout: o.Timeout, CheckRedirect: func(*http.Request, []*http.Request) error { return errors.New("redirect refused") }} response, err := client.Do(req) if err != nil { diff --git a/internal/deploy/operator_test.go b/internal/deploy/operator_test.go index c05ab40..583f5e6 100644 --- a/internal/deploy/operator_test.go +++ b/internal/deploy/operator_test.go @@ -5,6 +5,7 @@ package deploy import ( "context" "reflect" + "strings" "testing" ) @@ -23,13 +24,13 @@ func TestStartCandidateUsesArgumentVectorAndHardenedUnit(t *testing.T) { runner := &recordingRunner{} operator := SystemOperator{Runner: runner} env := map[string]string{"Z_ENV": "safe value", "A_ENV": "first"} - if err := operator.StartCandidate(context.Background(), "example-tend-candidate.service", "/opt/example/releases/sha256-a/app", env); err != nil { + if err := operator.StartCandidate(context.Background(), "example-tend-candidate.service", "/opt/example/releases/sha256-a/app", "/etc/tend/environment/example.env", env); err != nil { t.Fatal(err) } if runner.name != "systemd-run" { t.Fatalf("command=%q", runner.name) } - required := []string{"--property=DynamicUser=yes", "--property=NoNewPrivileges=yes", "--property=ProtectSystem=strict", "--property=MemoryDenyWriteExecute=yes", "--property=CapabilityBoundingSet=", "--setenv", "A_ENV=first", "--setenv", "Z_ENV=safe value", "--", "/opt/example/releases/sha256-a/app"} + required := []string{"--property=DynamicUser=yes", "--property=NoNewPrivileges=yes", "--property=ProtectSystem=strict", "--property=MemoryDenyWriteExecute=yes", "--property=CapabilityBoundingSet=", "--property=EnvironmentFile=/etc/tend/environment/example.env", "--setenv", "A_ENV=first", "--setenv", "Z_ENV=safe value", "--", "/opt/example/releases/sha256-a/app"} cursor := 0 for _, arg := range runner.args { if cursor < len(required) && arg == required[cursor] { @@ -42,4 +43,7 @@ func TestStartCandidateUsesArgumentVectorAndHardenedUnit(t *testing.T) { if reflect.DeepEqual(runner.args, []string{"sh", "-c"}) { t.Fatal("candidate command used a shell") } + if strings.Contains(strings.Join(runner.args, "\n"), "SUPER_SECRET") { + t.Fatal("candidate arguments exposed a secret value") + } } diff --git a/internal/serverpolicy/ownership_linux.go b/internal/serverpolicy/ownership_linux.go new file mode 100644 index 0000000..3fe1303 --- /dev/null +++ b/internal/serverpolicy/ownership_linux.go @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: AGPL-3.0-only +//go:build linux + +package serverpolicy + +import ( + "os" + "syscall" +) + +func rootOwned(info os.FileInfo) bool { + stat, ok := info.Sys().(*syscall.Stat_t) + return ok && stat.Uid == 0 +} diff --git a/internal/serverpolicy/ownership_other.go b/internal/serverpolicy/ownership_other.go new file mode 100644 index 0000000..a4954cf --- /dev/null +++ b/internal/serverpolicy/ownership_other.go @@ -0,0 +1,8 @@ +// SPDX-License-Identifier: AGPL-3.0-only +//go:build !linux + +package serverpolicy + +import "os" + +func rootOwned(os.FileInfo) bool { return false } diff --git a/internal/serverpolicy/policy.go b/internal/serverpolicy/policy.go new file mode 100644 index 0000000..19b4fc9 --- /dev/null +++ b/internal/serverpolicy/policy.go @@ -0,0 +1,300 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +// Package serverpolicy validates the root-owned allowlist used by Tend's +// restricted SSH receiver. It contains service names and paths, never secrets. +package serverpolicy + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + + "gamertan.com/tend/internal/config" +) + +const SchemaVersion = 1 + +var servicePattern = regexp.MustCompile(`^[a-z][a-z0-9-]{1,62}$`) + +type Policy struct { + SchemaVersion int `json:"schema_version"` + ConfigRoot string `json:"config_root"` + IncomingRoot string `json:"incoming_root"` + SharedLockFile string `json:"shared_lock_file"` + Services map[string]ServicePolicy `json:"services"` +} + +type ServicePolicy struct { + Config string `json:"config"` + MaxArtifactBytes int64 `json:"max_artifact_bytes"` +} + +type CheckedService struct { + Name string + Config config.Config + Policy ServicePolicy +} + +func Parse(reader io.Reader) (Policy, error) { + limited := io.LimitReader(reader, 1<<20+1) + body, err := io.ReadAll(limited) + if err != nil { + return Policy{}, err + } + if len(body) > 1<<20 { + return Policy{}, errors.New("server policy exceeds 1 MiB") + } + decoder := json.NewDecoder(bytes.NewReader(body)) + decoder.DisallowUnknownFields() + var policy Policy + if err = decoder.Decode(&policy); err != nil { + return Policy{}, fmt.Errorf("decode server policy: %w", err) + } + var trailing any + if err = decoder.Decode(&trailing); !errors.Is(err, io.EOF) { + return Policy{}, errors.New("server policy contains trailing data") + } + if err = policy.Validate(); err != nil { + return Policy{}, err + } + return policy, nil +} + +func Load(path string) (Policy, error) { + if err := secureDirectory(filepath.Dir(path), 0); err != nil { + return Policy{}, fmt.Errorf("server policy directory: %w", err) + } + if err := secureFile(path, 0o600); err != nil { + return Policy{}, fmt.Errorf("server policy: %w", err) + } + file, err := os.Open(path) + if err != nil { + return Policy{}, err + } + defer file.Close() + return Parse(file) +} + +func (policy Policy) Validate() error { + if policy.SchemaVersion != SchemaVersion { + return fmt.Errorf("server policy schema_version must be %d", SchemaVersion) + } + if policy.ConfigRoot != "/etc/tend/services" { + return errors.New("server policy config_root must be /etc/tend/services") + } + if policy.IncomingRoot != "/var/lib/tend/incoming" { + return errors.New("server policy incoming_root must be /var/lib/tend/incoming") + } + if policy.SharedLockFile != config.SharedLockFile { + return fmt.Errorf("server policy shared_lock_file must be %s", config.SharedLockFile) + } + if len(policy.Services) == 0 || len(policy.Services) > 128 { + return errors.New("server policy must allow 1 to 128 services") + } + for name, service := range policy.Services { + if !servicePattern.MatchString(name) { + return fmt.Errorf("invalid service name %q", name) + } + expected := filepath.Join(policy.ConfigRoot, name+".json") + if service.Config != expected { + return fmt.Errorf("service %s config must be %s", name, expected) + } + if service.MaxArtifactBytes < 1<<20 || service.MaxArtifactBytes > 512<<20 { + return fmt.Errorf("service %s artifact limit is invalid", name) + } + } + return nil +} + +func (policy Policy) CheckFiles() ([]CheckedService, error) { + if err := policy.CheckDirectories(); err != nil { + return nil, err + } + names := make([]string, 0, len(policy.Services)) + for name := range policy.Services { + names = append(names, name) + } + sort.Strings(names) + checked := make([]CheckedService, 0, len(names)) + for _, name := range names { + service, err := policy.CheckService(name) + if err != nil { + return nil, err + } + checked = append(checked, service) + } + return checked, nil +} + +func (policy Policy) CheckDirectories() error { + if err := secureDirectory(policy.ConfigRoot, 0); err != nil { + return fmt.Errorf("config root: %w", err) + } + if err := secureDirectory(policy.IncomingRoot, 0o700); err != nil { + return fmt.Errorf("incoming root: %w", err) + } + if err := secureDirectory("/etc/tend/environment", 0o700); err != nil { + return fmt.Errorf("environment root: %w", err) + } + return nil +} + +func (policy Policy) CheckService(name string) (CheckedService, error) { + entry, ok := policy.Services[name] + if !ok { + return CheckedService{}, errors.New("service is not allowed by server policy") + } + if err := secureFile(entry.Config, 0); err != nil { + return CheckedService{}, fmt.Errorf("service %s config: %w", name, err) + } + cfg, err := config.Load(entry.Config) + if err != nil { + return CheckedService{}, fmt.Errorf("service %s config: %w", name, err) + } + if cfg.Service.Name != name { + return CheckedService{}, fmt.Errorf("service %s config identity does not match", name) + } + if cfg.Deployment.LockFile != policy.SharedLockFile { + return CheckedService{}, fmt.Errorf("service %s does not use the host-wide lock", name) + } + if err = secureFile(cfg.Service.EnvironmentFile, 0o600); err != nil { + return CheckedService{}, fmt.Errorf("service %s environment file: %w", name, err) + } + if cfg.Deployment.Singleton != nil { + if err = rejectEnvironmentKey(cfg.Service.EnvironmentFile, cfg.Deployment.Singleton.ListenEnv); err != nil { + return CheckedService{}, fmt.Errorf("service %s environment file: %w", name, err) + } + } + return CheckedService{Name: name, Config: cfg, Policy: entry}, nil +} + +func CheckConfig(path string, cfg config.Config) error { + expected := filepath.Join("/etc/tend/services", cfg.Service.Name+".json") + if path != expected { + return fmt.Errorf("production config must be %s", expected) + } + if err := secureDirectory("/etc/tend/services", 0); err != nil { + return fmt.Errorf("config root: %w", err) + } + if err := secureDirectory("/etc/tend/environment", 0o700); err != nil { + return fmt.Errorf("environment root: %w", err) + } + if err := secureFile(path, 0); err != nil { + return fmt.Errorf("production config: %w", err) + } + if cfg.Deployment.LockFile != config.SharedLockFile { + return fmt.Errorf("deployment lock must be %s", config.SharedLockFile) + } + if err := secureFile(cfg.Service.EnvironmentFile, 0o600); err != nil { + return fmt.Errorf("environment file: %w", err) + } + if cfg.Deployment.Singleton != nil { + if err := rejectEnvironmentKey(cfg.Service.EnvironmentFile, cfg.Deployment.Singleton.ListenEnv); err != nil { + return fmt.Errorf("environment file: %w", err) + } + } + return nil +} + +func rejectEnvironmentKey(path, key string) error { + file, err := os.Open(path) + if err != nil { + return err + } + defer file.Close() + body, err := io.ReadAll(io.LimitReader(file, 1<<20+1)) + if err != nil { + return err + } + if len(body) > 1<<20 { + return errors.New("environment file exceeds 1 MiB") + } + for _, raw := range bytes.Split(body, []byte{'\n'}) { + line := strings.TrimSpace(string(raw)) + if !strings.HasPrefix(line, key) { + continue + } + remainder := strings.TrimSpace(strings.TrimPrefix(line, key)) + if strings.HasPrefix(remainder, "=") { + return errors.New("singleton candidate listen key must not be set in the shared environment file") + } + } + return nil +} + +func secureFile(path string, exactMode os.FileMode) error { + if !filepath.IsAbs(path) || filepath.Clean(path) != path || strings.ContainsRune(path, '\x00') { + return errors.New("path must be clean and absolute") + } + if err := rejectSymlinkAncestors(filepath.Dir(path)); err != nil { + return err + } + info, err := os.Lstat(path) + if err != nil { + return err + } + if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 { + return errors.New("must be a regular non-symlink file") + } + if !rootOwned(info) { + return errors.New("must be owned by root") + } + if exactMode != 0 && info.Mode().Perm() != exactMode { + return fmt.Errorf("mode must be %04o", exactMode) + } + if exactMode == 0 && info.Mode().Perm()&0o022 != 0 { + return errors.New("must not be group- or world-writable") + } + return nil +} + +func secureDirectory(path string, exactMode os.FileMode) error { + if !filepath.IsAbs(path) || filepath.Clean(path) != path || strings.ContainsRune(path, '\x00') { + return errors.New("path must be clean and absolute") + } + if err := rejectSymlinkAncestors(filepath.Dir(path)); err != nil { + return err + } + info, err := os.Lstat(path) + if err != nil { + return err + } + if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { + return errors.New("must be a real directory") + } + if !rootOwned(info) { + return errors.New("must be owned by root") + } + if exactMode != 0 && info.Mode().Perm() != exactMode { + return fmt.Errorf("mode must be %04o", exactMode) + } + if exactMode == 0 && info.Mode().Perm()&0o022 != 0 { + return errors.New("must not be group- or world-writable") + } + return nil +} + +func rejectSymlinkAncestors(path string) error { + current := string(filepath.Separator) + for _, part := range strings.Split(strings.TrimPrefix(filepath.Clean(path), string(filepath.Separator)), string(filepath.Separator)) { + if part == "" { + continue + } + current = filepath.Join(current, part) + info, err := os.Lstat(current) + if err != nil { + return err + } + if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("symlink or non-directory ancestor refused: %s", current) + } + } + return nil +} diff --git a/internal/serverpolicy/policy_test.go b/internal/serverpolicy/policy_test.go new file mode 100644 index 0000000..36c0ad2 --- /dev/null +++ b/internal/serverpolicy/policy_test.go @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +package serverpolicy + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestParseAcceptsStrictServiceMap(t *testing.T) { + body := `{"schema_version":1,"config_root":"/etc/tend/services","incoming_root":"/var/lib/tend/incoming","shared_lock_file":"/run/lock/tend-deploy.lock","services":{"example-site":{"config":"/etc/tend/services/example-site.json","max_artifact_bytes":1048576}}}` + policy, err := Parse(strings.NewReader(body)) + if err != nil { + t.Fatal(err) + } + if policy.Services["example-site"].MaxArtifactBytes != 1<<20 { + t.Fatalf("policy=%+v", policy) + } +} + +func TestRejectEnvironmentKeyProtectsSingletonCandidateOverride(t *testing.T) { + directory := t.TempDir() + path := filepath.Join(directory, "service.env") + for _, test := range []struct { + name string + body string + wantErr bool + }{ + {name: "shared values only", body: "APP_SECRET=private\n"}, + {name: "exact listen key", body: "EXAMPLE_LISTEN=127.0.0.1:8092\n", wantErr: true}, + {name: "spaced listen key", body: " EXAMPLE_LISTEN = 127.0.0.1:8092\n", wantErr: true}, + {name: "commented listen key", body: "# EXAMPLE_LISTEN=127.0.0.1:8092\n"}, + {name: "longer key", body: "EXAMPLE_LISTENER=safe\n"}, + } { + t.Run(test.name, func(t *testing.T) { + if err := os.WriteFile(path, []byte(test.body), 0o600); err != nil { + t.Fatal(err) + } + err := rejectEnvironmentKey(path, "EXAMPLE_LISTEN") + if (err != nil) != test.wantErr { + t.Fatalf("error=%v wantErr=%v", err, test.wantErr) + } + }) + } +} + +func TestParseRejectsUnknownAndForgedPaths(t *testing.T) { + tests := []string{ + `{"schema_version":1,"config_root":"/etc/tend/services","incoming_root":"/var/lib/tend/incoming","shared_lock_file":"/run/lock/tend-deploy.lock","services":{"example-site":{"config":"/tmp/example.json","max_artifact_bytes":1048576}}}`, + `{"schema_version":1,"config_root":"/etc/tend/services","incoming_root":"/var/lib/tend/incoming","shared_lock_file":"/run/lock/tend-deploy.lock","services":{},"surprise":true}`, + } + for _, body := range tests { + if _, err := Parse(strings.NewReader(body)); err == nil { + t.Fatalf("accepted %s", body) + } + } +} diff --git a/internal/transport/protocol.go b/internal/transport/protocol.go new file mode 100644 index 0000000..6a4ebf1 --- /dev/null +++ b/internal/transport/protocol.go @@ -0,0 +1,123 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +// Package transport implements Tend's bounded, versioned deployment stream. +package transport + +import ( + "bufio" + "bytes" + "crypto/sha256" + "encoding/binary" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "regexp" + "strings" +) + +const ( + Protocol = "tend-receive-v1" + MaxArtifactBytes = 512 << 20 + maxHeaderBytes = 64 << 10 + maxArtifactName = 128 +) + +var ( + servicePattern = regexp.MustCompile(`^[a-z][a-z0-9-]{1,62}$`) + artifactPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$`) +) + +type Header struct { + Protocol string `json:"protocol"` + Service string `json:"service"` + ArtifactName string `json:"artifact_name"` + Size int64 `json:"size"` + SHA256 string `json:"sha256"` + ApprovedSHA256 string `json:"approved_sha256"` + Activate bool `json:"activate"` +} + +func (h Header) Validate(maxBytes int64) error { + if h.Protocol != Protocol { + return errors.New("unsupported receive protocol") + } + if !servicePattern.MatchString(h.Service) { + return errors.New("invalid service name") + } + if len(h.ArtifactName) > maxArtifactName || !artifactPattern.MatchString(h.ArtifactName) { + return errors.New("invalid artifact name") + } + if h.Size <= 0 || h.Size > maxBytes { + return errors.New("artifact size exceeds policy") + } + if h.SHA256 != h.ApprovedSHA256 || len(h.SHA256) != 64 || strings.ToLower(h.SHA256) != h.SHA256 { + return errors.New("artifact digest was not explicitly approved") + } + if _, err := hex.DecodeString(h.SHA256); err != nil { + return errors.New("artifact digest is not hexadecimal") + } + return nil +} + +func Prefix(header Header) ([]byte, error) { + if err := header.Validate(MaxArtifactBytes); err != nil { + return nil, err + } + body, err := json.Marshal(header) + if err != nil { + return nil, err + } + if len(body) > maxHeaderBytes { + return nil, errors.New("receive header exceeds limit") + } + prefix := make([]byte, 4+len(body)) + binary.BigEndian.PutUint32(prefix[:4], uint32(len(body))) + copy(prefix[4:], body) + return prefix, nil +} + +func ReadHeader(reader *bufio.Reader) (Header, error) { + var size [4]byte + if _, err := io.ReadFull(reader, size[:]); err != nil { + return Header{}, fmt.Errorf("read receive header length: %w", err) + } + length := binary.BigEndian.Uint32(size[:]) + if length == 0 || length > maxHeaderBytes { + return Header{}, errors.New("receive header length is invalid") + } + body := make([]byte, length) + if _, err := io.ReadFull(reader, body); err != nil { + return Header{}, fmt.Errorf("read receive header: %w", err) + } + decoder := json.NewDecoder(bytes.NewReader(body)) + decoder.DisallowUnknownFields() + var header Header + if err := decoder.Decode(&header); err != nil { + return Header{}, fmt.Errorf("decode receive header: %w", err) + } + var trailing any + if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) { + return Header{}, errors.New("receive header contains trailing data") + } + return header, nil +} + +func CopyArtifact(destination io.Writer, reader *bufio.Reader, header Header, maxBytes int64) error { + if err := header.Validate(maxBytes); err != nil { + return err + } + hash := sha256.New() + written, err := io.CopyN(io.MultiWriter(destination, hash), reader, header.Size) + if err != nil || written != header.Size { + return errors.New("artifact stream ended before declared size") + } + if _, err = reader.ReadByte(); !errors.Is(err, io.EOF) { + return errors.New("artifact stream contains trailing bytes") + } + if hex.EncodeToString(hash.Sum(nil)) != header.SHA256 { + return errors.New("artifact stream digest does not match") + } + return nil +} diff --git a/internal/transport/protocol_test.go b/internal/transport/protocol_test.go new file mode 100644 index 0000000..1f93c58 --- /dev/null +++ b/internal/transport/protocol_test.go @@ -0,0 +1,81 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +package transport + +import ( + "bufio" + "bytes" + "crypto/sha256" + "encoding/hex" + "strings" + "testing" +) + +func validFrame(t *testing.T, artifact string) ([]byte, Header) { + t.Helper() + hash := sha256.Sum256([]byte(artifact)) + digest := hex.EncodeToString(hash[:]) + header := Header{Protocol: Protocol, Service: "example-site", ArtifactName: "example.tar.gz", Size: int64(len(artifact)), SHA256: digest, ApprovedSHA256: digest, Activate: true} + prefix, err := Prefix(header) + if err != nil { + t.Fatal(err) + } + return append(prefix, artifact...), header +} + +func TestProtocolRoundTrip(t *testing.T) { + frame, expected := validFrame(t, "artifact") + reader := bufio.NewReader(bytes.NewReader(frame)) + header, err := ReadHeader(reader) + if err != nil || header != expected { + t.Fatalf("header=%+v err=%v", header, err) + } + var artifact bytes.Buffer + if err = CopyArtifact(&artifact, reader, header, 1<<20); err != nil { + t.Fatal(err) + } + if artifact.String() != "artifact" { + t.Fatalf("artifact=%q", artifact.String()) + } +} + +func TestProtocolRejectsTrailingAndForgedInputs(t *testing.T) { + frame, header := validFrame(t, "artifact") + reader := bufio.NewReader(bytes.NewReader(append(frame, 'x'))) + read, _ := ReadHeader(reader) + if err := CopyArtifact(&bytes.Buffer{}, reader, read, 1<<20); err == nil { + t.Fatal("accepted trailing bytes") + } + header.Service = "../../root" + if _, err := Prefix(header); err == nil { + t.Fatal("accepted forged service") + } + header.Service = "example-site" + header.ApprovedSHA256 = strings.Repeat("0", 64) + if _, err := Prefix(header); err == nil { + t.Fatal("accepted unapproved digest") + } +} + +func FuzzProtocolFraming(f *testing.F) { + hash := sha256.Sum256([]byte("artifact")) + digest := hex.EncodeToString(hash[:]) + prefix, err := Prefix(Header{Protocol: Protocol, Service: "example-site", ArtifactName: "example.tar.gz", Size: 8, SHA256: digest, ApprovedSHA256: digest}) + if err != nil { + f.Fatal(err) + } + frame := append(prefix, []byte("artifact")...) + f.Add(frame) + f.Add([]byte{0, 0, 0, 0}) + f.Fuzz(func(t *testing.T, input []byte) { + if len(input) > 2<<20 { + t.Skip() + } + reader := bufio.NewReader(bytes.NewReader(input)) + header, err := ReadHeader(reader) + if err != nil { + return + } + _ = CopyArtifact(&bytes.Buffer{}, reader, header, 1<<20) + }) +} diff --git a/internal/transport/push.go b/internal/transport/push.go new file mode 100644 index 0000000..a02fa7b --- /dev/null +++ b/internal/transport/push.go @@ -0,0 +1,153 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +package transport + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "regexp" + "strconv" + "strings" +) + +var targetPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]*@[A-Za-z0-9][A-Za-z0-9.-]*$`) + +type PushOptions struct { + Target string + Port int + KnownHosts string + Identity string + Service string + Artifact string + SHA256 string + ApprovedSHA256 string + Activate bool +} + +type SSHRunner interface { + Run(context.Context, string, []string, io.Reader) ([]byte, error) +} + +type ExecSSHRunner struct{} + +func (ExecSSHRunner) Run(ctx context.Context, name string, args []string, stdin io.Reader) ([]byte, error) { + command := exec.CommandContext(ctx, name, args...) + command.Stdin = stdin + var output, diagnostic boundedBuffer + command.Stdout = &output + command.Stderr = &diagnostic + err := command.Run() + if err != nil { + return output.Bytes(), fmt.Errorf("ssh failed: %w: %s", err, strings.TrimSpace(diagnostic.String())) + } + return output.Bytes(), nil +} + +type boundedBuffer struct{ bytes.Buffer } + +func (b *boundedBuffer) Write(value []byte) (int, error) { + written := len(value) + remaining := (1 << 20) - b.Len() + if remaining > 0 { + if len(value) > remaining { + value = value[:remaining] + } + _, _ = b.Buffer.Write(value) + } + return written, nil +} + +func Push(ctx context.Context, runner SSHRunner, options PushOptions) (json.RawMessage, error) { + if !targetPattern.MatchString(options.Target) || strings.HasPrefix(options.Target, "-") { + return nil, errors.New("target must be user@host without shell syntax") + } + if options.Port < 1 || options.Port > 65535 { + return nil, errors.New("SSH port is invalid") + } + if err := safeClientFile(options.KnownHosts, false); err != nil { + return nil, fmt.Errorf("known-hosts file: %w", err) + } + if options.Identity != "" { + if err := safeClientFile(options.Identity, true); err != nil { + return nil, fmt.Errorf("identity file: %w", err) + } + } + if !filepath.IsAbs(options.Artifact) || filepath.Clean(options.Artifact) != options.Artifact || strings.ContainsAny(options.Artifact, "\x00\r\n\t") { + return nil, errors.New("artifact path must be clean and absolute") + } + artifactInfo, err := os.Lstat(options.Artifact) + if err != nil || !artifactInfo.Mode().IsRegular() || artifactInfo.Mode()&os.ModeSymlink != 0 || artifactInfo.Size() <= 0 || artifactInfo.Size() > MaxArtifactBytes { + return nil, errors.New("artifact must be a bounded regular non-symlink file") + } + artifact, err := os.Open(options.Artifact) + if err != nil { + return nil, err + } + defer artifact.Close() + info, err := artifact.Stat() + if err != nil || !info.Mode().IsRegular() || !os.SameFile(artifactInfo, info) { + return nil, errors.New("artifact must be a bounded regular file") + } + hash := sha256.New() + if _, err = io.Copy(hash, artifact); err != nil { + return nil, err + } + actual := hex.EncodeToString(hash.Sum(nil)) + if actual != options.SHA256 || options.SHA256 != options.ApprovedSHA256 { + return nil, errors.New("artifact digest was not explicitly approved") + } + if _, err = artifact.Seek(0, io.SeekStart); err != nil { + return nil, err + } + header := Header{Protocol: Protocol, Service: options.Service, ArtifactName: filepath.Base(options.Artifact), Size: info.Size(), SHA256: options.SHA256, ApprovedSHA256: options.ApprovedSHA256, Activate: options.Activate} + prefix, err := Prefix(header) + if err != nil { + return nil, err + } + args := []string{"-F", os.DevNull, "-T", "-p", strconv.Itoa(options.Port), "-o", "BatchMode=yes", "-o", "ClearAllForwardings=yes", "-o", "ExitOnForwardFailure=yes", "-o", "ForwardAgent=no", "-o", "IdentitiesOnly=yes", "-o", "LogLevel=ERROR", "-o", "PermitLocalCommand=no", "-o", "ProxyCommand=none", "-o", "RequestTTY=no", "-o", "StrictHostKeyChecking=yes", "-o", "UserKnownHostsFile=" + options.KnownHosts} + if options.Identity != "" { + args = append(args, "-i", options.Identity) + } + args = append(args, options.Target, Protocol) + output, err := runner.Run(ctx, "ssh", args, io.MultiReader(bytes.NewReader(prefix), artifact)) + if err != nil { + return nil, err + } + if !json.Valid(output) { + return nil, errors.New("receiver returned invalid JSON") + } + return json.RawMessage(output), nil +} + +func safeClientFile(path string, private bool) error { + if !filepath.IsAbs(path) || filepath.Clean(path) != path || strings.ContainsAny(path, "\x00\r\n\t") { + return errors.New("path must be clean and absolute") + } + parent, err := os.Lstat(filepath.Dir(path)) + if err != nil || !parent.IsDir() || parent.Mode()&os.ModeSymlink != 0 || parent.Mode().Perm()&0o022 != 0 { + return errors.New("parent must be a real directory not writable by group or others") + } + info, err := os.Lstat(path) + if err != nil { + return err + } + if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 { + return errors.New("must be a regular non-symlink file") + } + if info.Mode().Perm()&0o022 != 0 { + return errors.New("must not be group- or world-writable") + } + if private && info.Mode().Perm()&0o077 != 0 { + return errors.New("must not be accessible by group or others") + } + return nil +} diff --git a/internal/transport/push_test.go b/internal/transport/push_test.go new file mode 100644 index 0000000..2ec5b4e --- /dev/null +++ b/internal/transport/push_test.go @@ -0,0 +1,81 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +package transport + +import ( + "bufio" + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "io" + "os" + "path/filepath" + "slices" + "testing" +) + +type captureRunner struct { + name string + args []string + input []byte + calls int +} + +func (runner *captureRunner) Run(_ context.Context, name string, args []string, input io.Reader) ([]byte, error) { + runner.calls++ + runner.name = name + runner.args = append([]string(nil), args...) + runner.input, _ = io.ReadAll(input) + return []byte(`{"validated":true,"mutation":"activated"}`), nil +} + +func TestPushUsesPinnedSSHAndExactFrame(t *testing.T) { + dir := t.TempDir() + knownHosts := filepath.Join(dir, "known_hosts") + identity := filepath.Join(dir, "identity") + artifact := filepath.Join(dir, "release.tar.gz") + if err := os.WriteFile(knownHosts, []byte("host key\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(identity, []byte("private\n"), 0o600); err != nil { + t.Fatal(err) + } + content := []byte("artifact") + if err := os.WriteFile(artifact, content, 0o600); err != nil { + t.Fatal(err) + } + hash := sha256.Sum256(content) + digest := hex.EncodeToString(hash[:]) + runner := &captureRunner{} + result, err := Push(context.Background(), runner, PushOptions{Target: "tend-deploy@example.test", Port: 2222, KnownHosts: knownHosts, Identity: identity, Service: "example-site", Artifact: artifact, SHA256: digest, ApprovedSHA256: digest, Activate: true}) + if err != nil { + t.Fatal(err) + } + if !bytes.Contains(result, []byte(`"activated"`)) || runner.calls != 1 || runner.name != "ssh" { + t.Fatalf("result=%s calls=%d name=%q", result, runner.calls, runner.name) + } + if !slices.Contains(runner.args, "ProxyCommand=none") || !slices.Contains(runner.args, "StrictHostKeyChecking=yes") || runner.args[len(runner.args)-1] != Protocol { + t.Fatalf("args=%#v", runner.args) + } + reader := bufio.NewReader(bytes.NewReader(runner.input)) + header, err := ReadHeader(reader) + if err != nil { + t.Fatal(err) + } + var copied bytes.Buffer + if err = CopyArtifact(&copied, reader, header, 1<<20); err != nil { + t.Fatal(err) + } + if copied.String() != string(content) || header.Service != "example-site" || !header.Activate { + t.Fatalf("header=%+v body=%q", header, copied.String()) + } +} + +func TestPushRejectsShellTargetBeforeExecution(t *testing.T) { + runner := &captureRunner{} + _, err := Push(context.Background(), runner, PushOptions{Target: "root@example.test;touch", Port: 22}) + if err == nil || runner.calls != 0 { + t.Fatalf("err=%v calls=%d", err, runner.calls) + } +} diff --git a/internal/transport/receive.go b/internal/transport/receive.go new file mode 100644 index 0000000..f1d21b5 --- /dev/null +++ b/internal/transport/receive.go @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +package transport + +import ( + "bufio" + "context" + "fmt" + "io" + "os" + + "gamertan.com/tend/internal/deploy" + "gamertan.com/tend/internal/serverpolicy" +) + +func Receive(ctx context.Context, input io.Reader, policy serverpolicy.Policy, manager deploy.Manager) (deploy.Report, error) { + if err := policy.CheckDirectories(); err != nil { + return deploy.Report{}, err + } + reader := bufio.NewReaderSize(input, maxHeaderBytes+4) + header, err := ReadHeader(reader) + if err != nil { + return deploy.Report{}, err + } + service, err := policy.CheckService(header.Service) + if err != nil { + return deploy.Report{}, err + } + if err = header.Validate(service.Policy.MaxArtifactBytes); err != nil { + return deploy.Report{}, err + } + file, err := os.CreateTemp(policy.IncomingRoot, ".tend-receive-"+header.Service+"-") + if err != nil { + return deploy.Report{}, err + } + path := file.Name() + defer os.Remove(path) + if err = file.Chmod(0o600); err == nil { + err = CopyArtifact(file, reader, header, service.Policy.MaxArtifactBytes) + } + if closeErr := file.Close(); err == nil { + err = closeErr + } + if err != nil { + return deploy.Report{}, fmt.Errorf("receive artifact: %w", err) + } + return manager.Deploy(ctx, service.Config, deploy.Request{Artifact: path, SHA256: header.SHA256, ApprovedSHA256: header.ApprovedSHA256, Activate: header.Activate}) +} diff --git a/release/tend.json b/release/tend.json index 462877e..e11f053 100644 --- a/release/tend.json +++ b/release/tend.json @@ -1,6 +1,6 @@ { - "schema_version": 1, - "service": { "name": "tend", "allowed_host": "localhost" }, + "schema_version": 2, + "service": { "name": "tend", "allowed_host": "localhost", "environment_file": "/etc/tend/environment/tend-release-test.env" }, "build": { "package": "./cmd/tend", "binary": "tend", @@ -12,18 +12,18 @@ "deployment": { "strategy": "singleton_candidate", "root": "/opt/tend-release-test", - "lock_file": "/run/lock/tend-release-test.lock", + "lock_file": "/run/lock/tend-deploy.lock", "state_file": "/opt/tend-release-test/tend-state.json", "health_path": "/healthz", "readiness_path": "/readyz", "candidate_timeout_seconds": 5, "smoke": [{ "path": "/", "contains": "Tend" }], + "public_smoke": [{ "url": "https://example.test/", "contains": "Tend" }], "singleton": { "unit": "tend-release-test.service", "address": "127.0.0.1:19090", "candidate_address": "127.0.0.1:19091", "listen_env": "TEND_RELEASE_TEST_LISTEN", - "environment": {}, "current_link": "/opt/tend-release-test/current", "previous_link": "/opt/tend-release-test/previous" } diff --git a/scripts/public-snapshot.allow b/scripts/public-snapshot.allow index 993d5c8..7e4342b 100644 --- a/scripts/public-snapshot.allow +++ b/scripts/public-snapshot.allow @@ -9,12 +9,25 @@ cmd/tend/main.go docs/ARCHITECTURE.md docs/DOGFOOD_EVIDENCE.md docs/PUBLIC_SNAPSHOT.md +docs/SCHEMA_V2_MIGRATION.md docs/THREAT_MODEL.md +docs/WALKTHROUGH.md examples/LICENSE examples/README.md examples/blue-green/caddy-handler.template examples/blue-green/example-site@.service examples/blue-green/tend.json +examples/local/.env.example +examples/server/authorized_keys.example +examples/server/environment/docs-site.env.example +examples/server/environment/example-site.env.example +examples/server/example-singleton.service +examples/server/receive-policy.json +examples/server/services/docs-site.json +examples/server/services/example-site.json +examples/server/slots/example-site-blue.env +examples/server/slots/example-site-green.env +examples/server/tend-receive.sudoers examples/singleton/tend.json go.mod internal/config/config.go @@ -22,6 +35,7 @@ internal/config/config_test.go internal/deploy/deploy.go internal/deploy/deploy_test.go internal/deploy/lock_linux.go +internal/deploy/lock_linux_test.go internal/deploy/lock_other.go internal/deploy/operator.go internal/deploy/operator_test.go @@ -33,8 +47,17 @@ internal/packager/packager.go internal/packager/packager_test.go internal/process/run.go internal/provenance/git.go +internal/serverpolicy/ownership_linux.go +internal/serverpolicy/ownership_other.go +internal/serverpolicy/policy.go +internal/serverpolicy/policy_test.go internal/state/state.go internal/state/state_test.go +internal/transport/protocol.go +internal/transport/protocol_test.go +internal/transport/push.go +internal/transport/push_test.go +internal/transport/receive.go internal/version/version.go internal/version/version_test.go release/tend.json diff --git a/scripts/verify.sh b/scripts/verify.sh index 84c2d93..877cce5 100755 --- a/scripts/verify.sh +++ b/scripts/verify.sh @@ -14,6 +14,11 @@ GOWORK=off CGO_ENABLED=0 go build -mod=readonly -trimpath -o "$work/tend-2" ./cm cmp "$work/tend-1" "$work/tend-2" "$work/tend-1" check --config "$root/examples/blue-green/tend.json" >/dev/null "$work/tend-1" check --config "$root/examples/singleton/tend.json" >/dev/null +"$work/tend-1" check --config "$root/examples/server/services/example-site.json" >/dev/null +"$work/tend-1" check --config "$root/examples/server/services/docs-site.json" >/dev/null +"$work/tend-1" check --config "$root/release/tend.json" >/dev/null +grep -Fqx 'Defaults:tend-deploy env_keep += "SSH_ORIGINAL_COMMAND"' "$root/examples/server/tend-receive.sudoers" +grep -Fqx 'tend-deploy ALL=(root) NOPASSWD: /usr/local/bin/tend receive --policy /etc/tend/receive-policy.json' "$root/examples/server/tend-receive.sudoers" [[ $(GOWORK=off go list -m all | wc -l) -eq 1 ]] git diff --check echo "Tend verification passed"