Author SHA1 Message Date
gamertan 00d1dd4209 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 <crspeelman@gmail.com>
2026-08-16 19:02:08 -04:00
47 changed files with 1590 additions and 124 deletions
+2
View File
@@ -1,3 +1,5 @@
/bin/ /bin/
/dist/ /dist/
*.tmp *.tmp
.env.local
*.env.local
+1 -1
View File
@@ -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}
+1 -1
View File
@@ -1 +1 @@
b29d919cefddb06e0c47b453ffd55438944fc92da5732dbb6f98cb509a2c371e PUBLIC-SNAPSHOT.json d30559d85177736bed9cbf4e1fa2e0703e3195d42390539efd098f091d64b983 PUBLIC-SNAPSHOT.json
+34 -12
View File
@@ -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 build provenance, activates a health-checked candidate, and keeps rollback
state explicit. 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. - Caddy-switched blue/green services.
- A singleton service with an isolated transient candidate check. - A singleton service with an isolated transient candidate check.
@@ -17,21 +17,43 @@ shell hooks. Application-specific data activation remains application-specific.
```text ```text
tend check --config tend.json tend check --config tend.json
tend package --config tend.json --version v0.1.0-preview.2 --out dist tend package --config tend.json --version v0.2.0-preview.1 --out dist
tend deploy --config /etc/example/tend.json --artifact FILE --sha256 HEX --approve-sha256 HEX tend push --target tend-deploy@host --known-hosts FILE --service NAME --artifact FILE --sha256 HEX --approve-sha256 HEX
tend status --config /etc/example/tend.json tend receive --policy /etc/tend/receive-policy.json
tend rollback --config /etc/example/tend.json tend check-server --policy /etc/tend/receive-policy.json
tend prune --config /etc/example/tend.json --keep 3 [--apply] 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 `push` transfers one approved artifact through a pinned OpenSSH connection. A
credentials and production configuration stay outside source repositories. forced, no-shell receiver maps the service name to one root-owned configuration;
Dry-run and digest approval are intentional friction. 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 Schema 2 keeps all services under `/etc/tend/services/`, references a distinct
rollback/reactivation for both Gamertan and the Sandwich Hime website using one root-owned `0600` environment file for each service, and serializes activation
reviewed Tend candidate. See the dated 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. [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 The canonical public repository begins with a sanitized root snapshot rather
than the private development history. than the private development history.
+8
View File
@@ -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 source commit. Documentation-only changes after the recorded campaign require
one final identical-candidate maintenance pass before tagging. 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 `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 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. the CLI did not yet adopt the tagged module version from Go build information.
+7 -4
View File
@@ -4,11 +4,14 @@ Report suspected vulnerabilities privately to `security@sandwichhime.com`.
Please include the affected Tend version, configuration shape, reproduction, Please include the affected Tend version, configuration shape, reproduction,
and expected impact. Do not include production credentials or private logs. and expected impact. Do not include production credentials or private logs.
Tend treats source repositories, release artifacts, configuration, handwritten Tend treats reviewed source, pinned toolchains, root-owned server policy and
Caddy templates, and operators as trusted. It treats artifact paths, archives, 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 filesystem state, process output, HTTP responses, and deployment targets as
adversarial inputs. It never evaluates configuration as shell code. adversarial inputs. It never evaluates configuration as shell code.
The preview is not a sandbox and does not make an untrusted repository safe to 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 build. Run `tend package` only for reviewed source. Production configuration,
must be root-owned and kept outside repositories. 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.
+98 -13
View File
@@ -19,6 +19,8 @@ import (
"gamertan.com/tend/internal/deploy" "gamertan.com/tend/internal/deploy"
"gamertan.com/tend/internal/packager" "gamertan.com/tend/internal/packager"
"gamertan.com/tend/internal/process" "gamertan.com/tend/internal/process"
"gamertan.com/tend/internal/serverpolicy"
"gamertan.com/tend/internal/transport"
"gamertan.com/tend/internal/version" "gamertan.com/tend/internal/version"
) )
@@ -41,6 +43,12 @@ func run() error {
return packageCommand(ctx, os.Args[2:]) return packageCommand(ctx, os.Args[2:])
case "deploy": case "deploy":
return deployCommand(ctx, os.Args[2:]) 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": case "status":
return statusCommand(ctx, os.Args[2:]) return statusCommand(ctx, os.Args[2:])
case "rollback": case "rollback":
@@ -54,7 +62,7 @@ func run() error {
} }
} }
func usage() error { func usage() error {
return errors.New("usage: tend <check|package|deploy|status|rollback|prune|version> [options]") return errors.New("usage: tend <check|package|push|receive|check-server|deploy|status|rollback|prune|version> [options]")
} }
func checkCommand(args []string) error { func checkCommand(args []string) error {
@@ -123,7 +131,7 @@ func deployCommand(ctx context.Context, args []string) error {
return err return err
} }
if *activate { if *activate {
if err := requireMutationAuthority(absolute); err != nil { if err := requireMutationAuthority(absolute, cfg); err != nil {
return err return err
} }
} }
@@ -137,6 +145,90 @@ func deployCommand(ctx context.Context, args []string) error {
} }
return writeJSON(report) 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 { func statusCommand(ctx context.Context, args []string) error {
set := flag.NewFlagSet("status", flag.ContinueOnError) set := flag.NewFlagSet("status", flag.ContinueOnError)
set.SetOutput(os.Stderr) set.SetOutput(os.Stderr)
@@ -175,7 +267,7 @@ func rollbackCommand(ctx context.Context, args []string) error {
if !*activate { if !*activate {
return writeJSON(map[string]any{"validated": true, "mutation": "none", "message": "repeat with --activate to switch to the recorded previous release"}) 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 return err
} }
record, err := newManager().Rollback(ctx, cfg) record, err := newManager().Rollback(ctx, cfg)
@@ -201,7 +293,7 @@ func pruneCommand(args []string) error {
return err return err
} }
if *apply { if *apply {
if err := requireMutationAuthority(absolute); err != nil { if err := requireMutationAuthority(absolute, cfg); err != nil {
return err return err
} }
} }
@@ -225,21 +317,14 @@ func loadConfig(path string) (config.Config, string, error) {
func newManager() deploy.Manager { func newManager() deploy.Manager {
return deploy.NewManager(deploy.SystemOperator{Runner: process.ExecRunner{}, Timeout: 5 * time.Second}) 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" { if runtime.GOOS != "linux" {
return errors.New("deployment mutations require Linux") return errors.New("deployment mutations require Linux")
} }
if os.Geteuid() != 0 { if os.Geteuid() != 0 {
return errors.New("deployment mutations require root") return errors.New("deployment mutations require root")
} }
info, err := os.Stat(configPath) return serverpolicy.CheckConfig(configPath, cfg)
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
} }
func writeJSON(value any) error { func writeJSON(value any) error {
encoder := json.NewEncoder(os.Stdout) encoder := json.NewEncoder(os.Stdout)
+24 -9
View File
@@ -17,12 +17,26 @@ requires byte-identical output. The resulting archive contains only:
The external archive digest is the release identity used by deployment. 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 ## Target-host boundary
`tend deploy` runs on the Linux target. Transfer and SSH authentication stay Schema-2 configurations live below `/etc/tend/services/`. Secrets live only in
outside Tend v0.1; application-local transfer commands move the artifact and separate `/etc/tend/environment/*.env` files that are root-owned, non-symlink,
the identical Tend binary to a root-controlled staging directory. Tend then and mode `0600`. Configurations contain the path, never the values. Candidate
requires the expected digest to be repeated as a separately approved digest. 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 Dry-run validation extracts into a temporary directory and does not touch the
release tree. Activation acquires the shared lock, installs a content-addressed 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 separate loopback address, probes it, stops the candidate, changes the current
release pointer, and restarts the installed singleton unit. release pointer, and restarts the installed singleton unit.
Any failure before state persistence restores the previously observed Caddy After the local post-activation probes, Tend also checks configured HTTPS public
bytes and/or release pointers. Rollback is a separate explicit command over the origins. Any failure before state persistence restores the previously observed
recorded state. It rechecks health and readiness, but deliberately does not Caddy bytes and/or release pointers. Rollback is a separate explicit command
apply the current release's content markers to an older release whose routes over the recorded state. It rechecks local health/readiness and public reachability,
may differ. Pruning preserves both active and previous releases. 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.
+79
View File
@@ -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 general reliability claim. It records the acceptance campaign completed on
August 14, 2026 before Tend's first public preview. 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 ## Assessed candidate
- Source commit: `306d085e518cb4fe7b20a66d1e2ceb171e54ebdc`. - Source commit: `306d085e518cb4fe7b20a66d1e2ceb171e54ebdc`.
+5
View File
@@ -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 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 read-only discovery copy of the same public tree. Tags belong only to canonical
Gitea. 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.
+28
View File
@@ -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/<service>.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.
+23 -13
View File
@@ -14,26 +14,36 @@
- Caddy configuration validates before reload. - Caddy configuration validates before reload.
- An activation failure restores the previously observed state. - An activation failure restores the previously observed state.
- Active and previous releases survive pruning. - 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 ## Trusted inputs
Reviewed source, the Go toolchain, root-owned `tend.json`, systemd unit files, Reviewed source, the pinned Go toolchain, root-owned server policy and service
the Caddy handler template, the operator, and the target host's root account are configuration, systemd unit files, the Caddy handler template, the operator,
trusted. Packaging a hostile repository can execute hostile Go compiler hooks and the target host's root account are trusted. Packaging a hostile repository
or consume resources; Tend is not a source-code sandbox. can execute hostile Go compiler hooks or consume resources; Tend is not a
source-code sandbox.
## Adversarial inputs ## Adversarial inputs
Artifact paths and bytes, archive metadata, stale or malformed state, HTTP Artifact paths and bytes, protocol frames, requested service names, archive
responses, subprocess failures, and filesystem objects at managed paths are metadata, stale or malformed state, HTTP responses, subprocess failures, and
validated and fail closed. Process output is bounded. filesystem objects at managed paths are validated and fail closed. Process
output and receive fields are bounded.
## Non-goals and preview limits ## Non-goals and preview limits
Tend does not defend against a concurrently malicious root user, a compromised Tend does not defend against a concurrently malicious root user, a compromised
kernel/toolchain/systemd/Caddy installation, denial of service by the trusted kernel/toolchain/systemd/Caddy/OpenSSH installation, denial of service by the
application, or secrets already exposed through service configuration. v0.1 trusted application, or secrets an application itself exposes. It does not
does not manage data migrations, databases, containers, Kubernetes, or manage data migrations, databases, containers, Kubernetes, or application-specific
application-specific activation. Transfer remains an application-local, activation. EQL therefore remains outside the generic deployment adapter until
reviewed step until a transport can be added without weakening host-key and its SQLite/catalog publication checkpoints can be modeled explicitly.
credential boundaries.
+57
View File
@@ -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 <produced> --approve-sha256 <reviewed> --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.
+17 -6
View File
@@ -3,17 +3,28 @@
This subtree is licensed 0BSD so an operator can copy and adapt it without 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. 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 ```text
# /etc/example-site/blue.env # /etc/tend/environment/example-site.env
EXAMPLE_LISTEN=127.0.0.1:8090 APP_SECRET=replace-on-server
# /etc/example-site/green.env
EXAMPLE_LISTEN=127.0.0.1:8091
``` ```
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, Production configuration belongs outside the source checkout, owned by root,
and not group- or world-writable. The Caddy handler template is an entire and not group- or world-writable. The Caddy handler template is an entire
imported handler fragment; the enclosing site, matchers, and routing precedence imported handler fragment; the enclosing site, matchers, and routing precedence
remain operator-owned. 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.
+2 -1
View File
@@ -6,7 +6,8 @@ After=network.target
Type=simple Type=simple
DynamicUser=yes DynamicUser=yes
ExecStart=/opt/example-site/slots/%i/example-site 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 NoNewPrivileges=yes
PrivateTmp=yes PrivateTmp=yes
ProtectSystem=strict ProtectSystem=strict
+4 -3
View File
@@ -1,16 +1,17 @@
{ {
"schema_version": 1, "schema_version": 2,
"service": { "name": "example-site", "allowed_host": "example.test" }, "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" }, "build": { "package": "./cmd/site", "binary": "example-site", "branch": "main" },
"deployment": { "deployment": {
"strategy": "blue_green", "strategy": "blue_green",
"root": "/opt/example-site", "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", "state_file": "/opt/example-site/tend-state.json",
"health_path": "/healthz", "health_path": "/healthz",
"readiness_path": "/readyz", "readiness_path": "/readyz",
"candidate_timeout_seconds": 30, "candidate_timeout_seconds": 30,
"smoke": [{ "path": "/", "contains": "Example site" }], "smoke": [{ "path": "/", "contains": "Example site" }],
"public_smoke": [{ "url": "https://example.test/", "contains": "Example site" }],
"blue_green": { "blue_green": {
"caddy_config": "/etc/caddy/Caddyfile", "caddy_config": "/etc/caddy/Caddyfile",
"caddy_handler": "/etc/caddy/example-site-handler.caddy", "caddy_handler": "/etc/caddy/example-site-handler.caddy",
+3
View File
@@ -0,0 +1,3 @@
# SPDX-License-Identifier: 0BSD
APP_MODE=development
APP_SECRET=replace-with-a-local-random-value
+2
View File
@@ -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
@@ -0,0 +1,3 @@
# SPDX-License-Identifier: 0BSD
DOCS_LISTEN=127.0.0.1:8102
APP_SECRET=replace-on-server
@@ -0,0 +1,2 @@
# SPDX-License-Identifier: 0BSD
APP_SECRET=replace-on-server
+19
View File
@@ -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
+16
View File
@@ -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
}
}
}
+24
View File
@@ -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"
}
}
}
@@ -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"
}
}
}
@@ -0,0 +1,2 @@
# SPDX-License-Identifier: 0BSD
EXAMPLE_LISTEN=127.0.0.1:8090
@@ -0,0 +1,2 @@
# SPDX-License-Identifier: 0BSD
EXAMPLE_LISTEN=127.0.0.1:8091
+3
View File
@@ -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
+4 -4
View File
@@ -1,22 +1,22 @@
{ {
"schema_version": 1, "schema_version": 2,
"service": { "name": "example-site", "allowed_host": "example.test" }, "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" }, "build": { "package": "./cmd/site", "binary": "example-site", "branch": "main" },
"deployment": { "deployment": {
"strategy": "singleton_candidate", "strategy": "singleton_candidate",
"root": "/opt/example-site", "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", "state_file": "/opt/example-site/tend-state.json",
"health_path": "/healthz", "health_path": "/healthz",
"readiness_path": "/readyz", "readiness_path": "/readyz",
"candidate_timeout_seconds": 30, "candidate_timeout_seconds": 30,
"smoke": [{ "path": "/", "contains": "Example site" }], "smoke": [{ "path": "/", "contains": "Example site" }],
"public_smoke": [{ "url": "https://example.test/", "contains": "Example site" }],
"singleton": { "singleton": {
"unit": "example-site.service", "unit": "example-site.service",
"address": "127.0.0.1:8092", "address": "127.0.0.1:8092",
"candidate_address": "127.0.0.1:18092", "candidate_address": "127.0.0.1:18092",
"listen_env": "EXAMPLE_LISTEN", "listen_env": "EXAMPLE_LISTEN",
"environment": {},
"current_link": "/opt/example-site/current", "current_link": "/opt/example-site/current",
"previous_link": "/opt/example-site/previous" "previous_link": "/opt/example-site/previous"
} }
+45 -25
View File
@@ -9,13 +9,17 @@ import (
"fmt" "fmt"
"io" "io"
"net/netip" "net/netip"
"net/url"
"os" "os"
"path/filepath" "path/filepath"
"regexp" "regexp"
"strings" "strings"
) )
const SchemaVersion = 1 const (
SchemaVersion = 2
SharedLockFile = "/run/lock/tend-deploy.lock"
)
var ( var (
namePattern = regexp.MustCompile(`^[a-z][a-z0-9-]{1,62}$`) namePattern = regexp.MustCompile(`^[a-z][a-z0-9-]{1,62}$`)
@@ -33,8 +37,9 @@ type Config struct {
} }
type Service struct { type Service struct {
Name string `json:"name"` Name string `json:"name"`
AllowedHost string `json:"allowed_host"` AllowedHost string `json:"allowed_host"`
EnvironmentFile string `json:"environment_file"`
} }
type Build struct { type Build struct {
@@ -47,16 +52,17 @@ type Build struct {
} }
type Deployment struct { type Deployment struct {
Strategy string `json:"strategy"` Strategy string `json:"strategy"`
Root string `json:"root"` Root string `json:"root"`
LockFile string `json:"lock_file"` LockFile string `json:"lock_file"`
StateFile string `json:"state_file"` StateFile string `json:"state_file"`
HealthPath string `json:"health_path"` HealthPath string `json:"health_path"`
ReadinessPath string `json:"readiness_path"` ReadinessPath string `json:"readiness_path"`
CandidateTimeoutSecs int `json:"candidate_timeout_seconds"` CandidateTimeoutSecs int `json:"candidate_timeout_seconds"`
Smoke []Smoke `json:"smoke"` Smoke []Smoke `json:"smoke"`
BlueGreen *BlueGreen `json:"blue_green,omitempty"` PublicSmoke []PublicSmoke `json:"public_smoke"`
Singleton *Singleton `json:"singleton,omitempty"` BlueGreen *BlueGreen `json:"blue_green,omitempty"`
Singleton *Singleton `json:"singleton,omitempty"`
} }
type Smoke struct { type Smoke struct {
@@ -64,6 +70,11 @@ type Smoke struct {
Contains string `json:"contains"` Contains string `json:"contains"`
} }
type PublicSmoke struct {
URL string `json:"url"`
Contains string `json:"contains"`
}
type BlueGreen struct { type BlueGreen struct {
CaddyConfig string `json:"caddy_config"` CaddyConfig string `json:"caddy_config"`
CaddyHandler string `json:"caddy_handler"` CaddyHandler string `json:"caddy_handler"`
@@ -80,13 +91,12 @@ type Slot struct {
} }
type Singleton struct { type Singleton struct {
Unit string `json:"unit"` Unit string `json:"unit"`
Address string `json:"address"` Address string `json:"address"`
CandidateAddress string `json:"candidate_address"` CandidateAddress string `json:"candidate_address"`
ListenEnv string `json:"listen_env"` ListenEnv string `json:"listen_env"`
Environment map[string]string `json:"environment,omitempty"` CurrentLink string `json:"current_link"`
CurrentLink string `json:"current_link"` PreviousLink string `json:"previous_link"`
PreviousLink string `json:"previous_link"`
} }
func Load(path string) (Config, error) { 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 ") { if c.Service.AllowedHost == "" || strings.ContainsAny(c.Service.AllowedHost, "/\\\x00\r\n\t ") {
return errors.New("service.allowed_host is invalid") 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, "..") { if !packagePattern.MatchString(c.Build.Package) || strings.Contains(c.Build.Package, "..") {
return errors.New("build.package must be a local package without traversal") 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) 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 { switch d.Strategy {
case "blue_green": case "blue_green":
if d.BlueGreen == nil || d.Singleton != nil { if d.BlueGreen == nil || d.Singleton != nil {
@@ -273,11 +298,6 @@ func validateSingleton(root string, s Singleton) error {
if s.CurrentLink == s.PreviousLink { if s.CurrentLink == s.PreviousLink {
return errors.New("current and previous links must differ") 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 return nil
} }
+13 -9
View File
@@ -9,13 +9,14 @@ import (
func validConfig() Config { func validConfig() Config {
return Config{ return Config{
SchemaVersion: 1, SchemaVersion: 2,
Service: Service{Name: "example-site", AllowedHost: "example.test"}, 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"}, Build: Build{Package: "./cmd/site", Binary: "example-site", Branch: "main"},
Deployment: Deployment{ 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", StateFile: "/opt/example-site/state.json", HealthPath: "/healthz", ReadinessPath: "/readyz",
CandidateTimeoutSecs: 30, Smoke: []Smoke{{Path: "/", Contains: "Example"}}, CandidateTimeoutSecs: 30, Smoke: []Smoke{{Path: "/", Contains: "Example"}},
PublicSmoke: []PublicSmoke{{URL: "https://example.test/", Contains: "Example"}},
BlueGreen: &BlueGreen{ BlueGreen: &BlueGreen{
CaddyConfig: "/etc/caddy/Caddyfile", CaddyHandler: "/etc/caddy/example.caddy", CaddyConfig: "/etc/caddy/Caddyfile", CaddyHandler: "/etc/caddy/example.caddy",
CaddyHandlerTemplate: "/etc/example/caddy.template", CaddyHandlerTemplate: "/etc/example/caddy.template",
@@ -35,12 +36,15 @@ func TestValidateAcceptsBlueGreen(t *testing.T) {
func TestValidateRejectsHostileValues(t *testing.T) { func TestValidateRejectsHostileValues(t *testing.T) {
tests := map[string]func(*Config){ tests := map[string]func(*Config){
"unknown strategy": func(c *Config) { c.Deployment.Strategy = "shell" }, "unknown strategy": func(c *Config) { c.Deployment.Strategy = "shell" },
"nonloopback": func(c *Config) { c.Deployment.BlueGreen.Blue.Address = "203.0.113.7:80" }, "nonloopback": func(c *Config) { c.Deployment.BlueGreen.Blue.Address = "203.0.113.7:80" },
"root path": func(c *Config) { c.Deployment.Root = "/" }, "root path": func(c *Config) { c.Deployment.Root = "/" },
"traversal": func(c *Config) { c.Build.Package = "./cmd/../secret" }, "traversal": func(c *Config) { c.Build.Package = "./cmd/../secret" },
"shared slot": func(c *Config) { c.Deployment.BlueGreen.Green.Link = c.Deployment.BlueGreen.Blue.Link }, "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 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 { for name, mutate := range tests {
t.Run(name, func(t *testing.T) { t.Run(name, func(t *testing.T) {
+31 -10
View File
@@ -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 { if err = m.probeAll(ctx, cfg, slot.Address); err != nil {
return fmt.Errorf("post-activation smoke failed: %w", err) 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)} 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 { if err = state.Store(cfg.Deployment.StateFile, cfg.Deployment.Root, next); err != nil {
return err 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) { func (m Manager) deploySingleton(ctx context.Context, cfg config.Config, record state.Record, release string) (err error) {
single := *cfg.Deployment.Singleton single := *cfg.Deployment.Singleton
candidateUnit := cfg.Service.Name + "-tend-candidate.service" candidateUnit := cfg.Service.Name + "-tend-candidate.service"
env := copyMap(single.Environment) env := map[string]string{single.ListenEnv: single.CandidateAddress}
env[single.ListenEnv] = single.CandidateAddress
binary := filepath.Join(release, cfg.Build.Binary) 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 return err
} }
defer func() { 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 { if err = m.probeAll(ctx, cfg, single.Address); err != nil {
return fmt.Errorf("post-activation smoke failed: %w", err) 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)} 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 { if err = state.Store(cfg.Deployment.StateFile, cfg.Deployment.Root, next); err != nil {
return err 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 { if err = m.probeHealthReadiness(ctx, cfg, slot.Address); err != nil {
return err 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)} 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) 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 { if err = m.probeHealthReadiness(ctx, cfg, single.Address); err != nil {
return err return err
} }
if err = m.probePublic(ctx, cfg, false); err != nil {
return err
}
_ = replaceSymlink(single.PreviousLink, record.ActiveRelease) _ = 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)} 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) 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) 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 { func (m Manager) probe(ctx context.Context, cfg config.Config, address string, checks []config.Smoke) error {
timeout := time.Duration(cfg.Deployment.CandidateTimeoutSecs) * time.Second timeout := time.Duration(cfg.Deployment.CandidateTimeoutSecs) * time.Second
for _, check := range checks { for _, check := range checks {
@@ -465,13 +493,6 @@ func slotConfig(bg config.BlueGreen, name string) config.Slot {
} }
return bg.Green 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) { func resolveReleaseLink(root, link string) (string, error) {
info, err := os.Lstat(link) info, err := os.Lstat(link)
if err != nil { if err != nil {
+54 -3
View File
@@ -22,6 +22,10 @@ type fakeOperator struct {
active map[string]bool active map[string]bool
starts, stops, restarts []string starts, stops, restarts []string
probes []string probes []string
publicProbes []string
failPublic bool
candidateEnvironment map[string]string
candidateFile string
} }
func (f *fakeOperator) Restart(_ context.Context, unit string) error { 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) { func (f *fakeOperator) IsActive(_ context.Context, unit string) (bool, error) {
return f.active[unit], nil return f.active[unit], nil
} }
func (f *fakeOperator) StartCandidate(_ context.Context, unit, binary string, env map[string]string) error { func (f *fakeOperator) StartCandidate(_ context.Context, unit, binary, environmentFile string, env map[string]string) error {
if !filepath.IsAbs(binary) || len(env) == 0 { if !filepath.IsAbs(binary) || !filepath.IsAbs(environmentFile) || len(env) == 0 {
return errors.New("bad candidate") return errors.New("bad candidate")
} }
f.starts = append(f.starts, unit) 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 f.active[unit] = true
return nil 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) ValidateCaddy(context.Context, string) error { return nil }
func (f *fakeOperator) ReloadCaddy(context.Context) error { func (f *fakeOperator) ReloadCaddy(context.Context) error {
if f.failReload { if f.failReload {
@@ -79,7 +127,7 @@ func baseConfig(t *testing.T, strategy string) (config.Config, string, string) {
t.Fatal(err) 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 return cfg, old, fresh
} }
func manager(operator Operator, fresh string) Manager { func manager(operator Operator, fresh string) Manager {
@@ -210,4 +258,7 @@ func TestStatePersistsOnlyAfterSuccessfulActivation(t *testing.T) {
if record.ActiveRelease != fresh || record.PreviousRelease != old { if record.ActiveRelease != fresh || record.PreviousRelease != old {
t.Fatalf("state=%+v", record) 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)
}
} }
+30
View File
@@ -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()
}
+18 -4
View File
@@ -20,10 +20,11 @@ type Operator interface {
Restart(context.Context, string) error Restart(context.Context, string) error
Stop(context.Context, string) error Stop(context.Context, string) error
IsActive(context.Context, string) (bool, 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 ValidateCaddy(context.Context, string) error
ReloadCaddy(context.Context) error ReloadCaddy(context.Context) error
Probe(context.Context, string, string, string, string) error Probe(context.Context, string, string, string, string) error
ProbeURL(context.Context, string, string) error
} }
type SystemOperator struct { 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 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{ args := []string{
"--unit", unit, "--collect", "--unit", unit, "--collect",
"--property=DynamicUser=yes", "--property=NoNewPrivileges=yes", "--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=RestrictSUIDSGID=yes", "--property=LockPersonality=yes",
"--property=MemoryDenyWriteExecute=yes", "--property=CapabilityBoundingSet=", "--property=MemoryDenyWriteExecute=yes", "--property=CapabilityBoundingSet=",
"--property=AmbientCapabilities=", "--property=AmbientCapabilities=",
"--property=EnvironmentFile=" + environmentFile,
} }
keys := make([]string, 0, len(env)) keys := make([]string, 0, len(env))
for key := range 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 { func (o SystemOperator) Probe(ctx context.Context, address, host, path, contains string) error {
u := url.URL{Scheme: "http", Host: address, Path: path} 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 { if err != nil {
return err 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") }} client := &http.Client{Timeout: o.Timeout, CheckRedirect: func(*http.Request, []*http.Request) error { return errors.New("redirect refused") }}
response, err := client.Do(req) response, err := client.Do(req)
if err != nil { if err != nil {
+6 -2
View File
@@ -5,6 +5,7 @@ package deploy
import ( import (
"context" "context"
"reflect" "reflect"
"strings"
"testing" "testing"
) )
@@ -23,13 +24,13 @@ func TestStartCandidateUsesArgumentVectorAndHardenedUnit(t *testing.T) {
runner := &recordingRunner{} runner := &recordingRunner{}
operator := SystemOperator{Runner: runner} operator := SystemOperator{Runner: runner}
env := map[string]string{"Z_ENV": "safe value", "A_ENV": "first"} 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) t.Fatal(err)
} }
if runner.name != "systemd-run" { if runner.name != "systemd-run" {
t.Fatalf("command=%q", runner.name) 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 cursor := 0
for _, arg := range runner.args { for _, arg := range runner.args {
if cursor < len(required) && arg == required[cursor] { if cursor < len(required) && arg == required[cursor] {
@@ -42,4 +43,7 @@ func TestStartCandidateUsesArgumentVectorAndHardenedUnit(t *testing.T) {
if reflect.DeepEqual(runner.args, []string{"sh", "-c"}) { if reflect.DeepEqual(runner.args, []string{"sh", "-c"}) {
t.Fatal("candidate command used a shell") 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")
}
} }
+14
View File
@@ -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
}
+8
View File
@@ -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 }
+300
View File
@@ -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
}
+59
View File
@@ -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)
}
}
}
+123
View File
@@ -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
}
+81
View File
@@ -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)
})
}
+153
View File
@@ -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
}
+81
View File
@@ -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)
}
}
+48
View File
@@ -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})
}
+4 -4
View File
@@ -1,6 +1,6 @@
{ {
"schema_version": 1, "schema_version": 2,
"service": { "name": "tend", "allowed_host": "localhost" }, "service": { "name": "tend", "allowed_host": "localhost", "environment_file": "/etc/tend/environment/tend-release-test.env" },
"build": { "build": {
"package": "./cmd/tend", "package": "./cmd/tend",
"binary": "tend", "binary": "tend",
@@ -12,18 +12,18 @@
"deployment": { "deployment": {
"strategy": "singleton_candidate", "strategy": "singleton_candidate",
"root": "/opt/tend-release-test", "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", "state_file": "/opt/tend-release-test/tend-state.json",
"health_path": "/healthz", "health_path": "/healthz",
"readiness_path": "/readyz", "readiness_path": "/readyz",
"candidate_timeout_seconds": 5, "candidate_timeout_seconds": 5,
"smoke": [{ "path": "/", "contains": "Tend" }], "smoke": [{ "path": "/", "contains": "Tend" }],
"public_smoke": [{ "url": "https://example.test/", "contains": "Tend" }],
"singleton": { "singleton": {
"unit": "tend-release-test.service", "unit": "tend-release-test.service",
"address": "127.0.0.1:19090", "address": "127.0.0.1:19090",
"candidate_address": "127.0.0.1:19091", "candidate_address": "127.0.0.1:19091",
"listen_env": "TEND_RELEASE_TEST_LISTEN", "listen_env": "TEND_RELEASE_TEST_LISTEN",
"environment": {},
"current_link": "/opt/tend-release-test/current", "current_link": "/opt/tend-release-test/current",
"previous_link": "/opt/tend-release-test/previous" "previous_link": "/opt/tend-release-test/previous"
} }
+23
View File
@@ -9,12 +9,25 @@ cmd/tend/main.go
docs/ARCHITECTURE.md docs/ARCHITECTURE.md
docs/DOGFOOD_EVIDENCE.md docs/DOGFOOD_EVIDENCE.md
docs/PUBLIC_SNAPSHOT.md docs/PUBLIC_SNAPSHOT.md
docs/SCHEMA_V2_MIGRATION.md
docs/THREAT_MODEL.md docs/THREAT_MODEL.md
docs/WALKTHROUGH.md
examples/LICENSE examples/LICENSE
examples/README.md examples/README.md
examples/blue-green/caddy-handler.template examples/blue-green/caddy-handler.template
examples/blue-green/example-site@.service examples/blue-green/example-site@.service
examples/blue-green/tend.json 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 examples/singleton/tend.json
go.mod go.mod
internal/config/config.go internal/config/config.go
@@ -22,6 +35,7 @@ internal/config/config_test.go
internal/deploy/deploy.go internal/deploy/deploy.go
internal/deploy/deploy_test.go internal/deploy/deploy_test.go
internal/deploy/lock_linux.go internal/deploy/lock_linux.go
internal/deploy/lock_linux_test.go
internal/deploy/lock_other.go internal/deploy/lock_other.go
internal/deploy/operator.go internal/deploy/operator.go
internal/deploy/operator_test.go internal/deploy/operator_test.go
@@ -33,8 +47,17 @@ internal/packager/packager.go
internal/packager/packager_test.go internal/packager/packager_test.go
internal/process/run.go internal/process/run.go
internal/provenance/git.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.go
internal/state/state_test.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.go
internal/version/version_test.go internal/version/version_test.go
release/tend.json release/tend.json
+5
View File
@@ -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" 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/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/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 ]] [[ $(GOWORK=off go list -m all | wc -l) -eq 1 ]]
git diff --check git diff --check
echo "Tend verification passed" echo "Tend verification passed"