Files
orca/internal/txn/txn.go
T
Jon Chery dfacfea377 fix(P03): txn apply path allowlist (REQ-121, F5)
---ci---
project: orca
phase: 3
milestone: v0.12
status: execute
---/ci---

apply.sh python heredoc now validates every path in desired-state.json
against a prefix allowlist (/etc/orca/, /etc/traefik/orca*,
/etc/systemd/system/orca-*, /etc/nftables.d/orca*, /etc/syncthing/orca*).
Rejects with exit 7 on mismatch. Also rejects .. traversal and relative
paths. HMAC-signed manifest unchanged. 8 regression tests including
/etc/orca/../../shadow traversal attempt.
2026-08-07 10:56:25 +00:00

465 lines
13 KiB
Go

// Package txn implements orca's transactional control-plane update
// mechanism (P10a, v0.11 milestone; REQ-075, REQ-079; gates C-09, C-23).
//
// The model is ArgoCD-style desired-state + lead-applier:
//
// - RenderBundle marshals a desired-state object to JSON, computes a
// content-addressed TxnID (T- + first 16 hex chars of SHA-256 of
// the JSON), and generates apply.sh / verify.sh / rollback.sh
// scripts plus a signed manifest (HMAC-SHA256 under the cluster
// master key). The bundle is self-contained and reproducible: the
// same desired-state + key always yields the same TxnID and the
// same scripts.
// - Stage SCPs the bundle to the lead peer's /run/orca/txns/<txn-id>/
// directory using the sshpush transport (idempotent writes).
// - Apply runs apply.sh on the lead (idempotent: re-running an
// already-applied txn is a no-op), then verify.sh. On verify
// failure it runs rollback.sh and returns an error. The
// namespace-scoped vs cluster-wide distinction (C-23) is enforced
// by the caller via ApplyOptions.Namespace and the lead-side
// orca-pull.sh script.
//
// The package never logs key material. slog calls carry only metadata.
package txn
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"log/slog"
"os"
"strings"
"time"
"git.cloudinit.dev/coreci/orca/internal/sshpush"
)
const (
txnIDPrefix = "T-"
txnIDHexLen = 16
remoteTxnRoot = "/run/orca/txns"
)
const (
fileDesiredState = "desired-state.json"
fileApply = "apply.sh"
fileVerify = "verify.sh"
fileRollback = "rollback.sh"
fileManifest = "manifest.json"
fileManifestSig = "manifest.sig"
fileApplied = ".applied"
)
type TxnID string
func (id TxnID) String() string { return string(id) }
type ManifestEntry struct {
Name string `json:"name"`
SHA256 string `json:"sha256"`
}
type Manifest struct {
TxnID TxnID `json:"txn_id"`
Timestamp string `json:"timestamp"`
Files []ManifestEntry `json:"files"`
}
type Bundle struct {
ID TxnID
DesiredState json.RawMessage
ApplyScript []byte
VerifyScript []byte
RollbackScript []byte
Manifest []byte
ManifestSig []byte
}
type ApplyOptions struct {
Force bool
AcknowledgeRisk bool
Yes bool
Namespace string
Timeout time.Duration
}
// Transport is the SSH-push surface the txn package needs: writing
// files idempotently and running remote commands. *sshpush.Transport
// satisfies it; tests substitute a mock to assert staging and apply
// behavior without a real SSH server (same pattern as
// internal/emitter.AtomicWriter).
type Transport interface {
WriteFileIdempotent(ctx context.Context, peer string, path string, content []byte, mode os.FileMode) (bool, error)
Exec(ctx context.Context, peer string, cmd string) ([]byte, error)
}
var (
ErrSignatureMismatch = errors.New("txn: manifest signature mismatch")
ErrNotApplied = errors.New("txn: not applied")
ErrAlreadyApplied = errors.New("txn: already applied")
ErrClusterWideRequiresForce = errors.New("cluster-wide txn requires --force")
ErrClusterWideRequiresAck = errors.New("cluster-wide --force requires --i-understand-the-risk (or --yes)")
)
func RenderBundle(desiredState any, masterKey []byte) (*Bundle, error) {
if len(masterKey) == 0 {
return nil, fmt.Errorf("txn: master key is empty")
}
data, err := json.MarshalIndent(desiredState, "", " ")
if err != nil {
return nil, fmt.Errorf("txn: marshal desired state: %w", err)
}
var norm any
if err := json.Unmarshal(data, &norm); err != nil {
return nil, fmt.Errorf("txn: normalize desired state: %w", err)
}
canonical, err := json.Marshal(norm)
if err != nil {
return nil, fmt.Errorf("txn: re-marshal desired state: %w", err)
}
id, err := computeTxnID(canonical)
if err != nil {
return nil, err
}
ts := time.Now().UTC().Format(time.RFC3339Nano)
apply := renderApplyScript(id)
verify := renderVerifyScript(id)
rollback := renderRollbackScript(id)
manifest, err := buildManifest(id, ts, canonical, apply, verify, rollback)
if err != nil {
return nil, err
}
sig := signManifest(manifest, masterKey)
slog.Info("txn bundle rendered", "txn_id", id, "desired_bytes", len(canonical))
return &Bundle{
ID: id,
DesiredState: canonical,
ApplyScript: apply,
VerifyScript: verify,
RollbackScript: rollback,
Manifest: manifest,
ManifestSig: sig,
}, nil
}
func computeTxnID(data []byte) (TxnID, error) {
sum := sha256.Sum256(data)
h := hex.EncodeToString(sum[:])
if len(h) < txnIDHexLen {
return "", fmt.Errorf("txn: sha256 hex too short")
}
return TxnID(txnIDPrefix + h[:txnIDHexLen]), nil
}
func sha256Hex(b []byte) string {
sum := sha256.Sum256(b)
return hex.EncodeToString(sum[:])
}
func buildManifest(id TxnID, ts string, desired, apply, verify, rollback []byte) ([]byte, error) {
m := Manifest{
TxnID: id,
Timestamp: ts,
Files: []ManifestEntry{
{Name: fileDesiredState, SHA256: sha256Hex(desired)},
{Name: fileApply, SHA256: sha256Hex(apply)},
{Name: fileVerify, SHA256: sha256Hex(verify)},
{Name: fileRollback, SHA256: sha256Hex(rollback)},
},
}
out, err := json.MarshalIndent(m, "", " ")
if err != nil {
return nil, fmt.Errorf("txn: marshal manifest: %w", err)
}
return out, nil
}
func signManifest(manifest, masterKey []byte) []byte {
mac := hmac.New(sha256.New, masterKey)
mac.Write(manifest)
return []byte(hex.EncodeToString(mac.Sum(nil)))
}
func VerifyManifestSignature(manifest, sigHex, masterKey []byte) error {
if len(masterKey) == 0 {
return fmt.Errorf("txn: master key is empty")
}
mac := hmac.New(sha256.New, masterKey)
mac.Write(manifest)
got := mac.Sum(nil)
want, err := hex.DecodeString(string(sigHex))
if err != nil {
return fmt.Errorf("txn: decode manifest signature: %w", err)
}
if !hmac.Equal(got, want) {
return ErrSignatureMismatch
}
return nil
}
func remoteTxnDir(id TxnID) string {
return remoteTxnRoot + "/" + string(id)
}
func Stage(bundle *Bundle, leadPeer string, transport Transport) error {
if bundle == nil {
return fmt.Errorf("txn: nil bundle")
}
if leadPeer == "" {
return fmt.Errorf("txn: lead peer is empty")
}
if transport == nil {
return fmt.Errorf("txn: nil transport")
}
dir := remoteTxnDir(bundle.ID)
ctx := context.Background()
files := []struct {
name string
content []byte
mode os.FileMode
}{
{fileDesiredState, bundle.DesiredState, 0o644},
{fileApply, bundle.ApplyScript, 0o755},
{fileVerify, bundle.VerifyScript, 0o755},
{fileRollback, bundle.RollbackScript, 0o755},
{fileManifest, bundle.Manifest, 0o644},
{fileManifestSig, bundle.ManifestSig, 0o644},
}
for _, f := range files {
path := dir + "/" + f.name
if _, err := transport.WriteFileIdempotent(ctx, leadPeer, path, f.content, f.mode); err != nil {
return fmt.Errorf("txn: stage %s: %w", f.name, err)
}
}
slog.Info("txn staged", "txn_id", bundle.ID, "peer", leadPeer, "dir", dir)
return nil
}
func Apply(ctx context.Context, txnID TxnID, leadPeer string, transport Transport, opts ApplyOptions) error {
if transport == nil {
return fmt.Errorf("txn: nil transport")
}
if leadPeer == "" {
return fmt.Errorf("txn: lead peer is empty")
}
dir := remoteTxnDir(txnID)
pull := dir + "/orca-pull.sh"
if opts.Timeout > 0 {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, opts.Timeout)
defer cancel()
}
cmd := fmt.Sprintf("bash %s --txn-dir %s", pull, shellQuote(dir))
if opts.Namespace != "" {
cmd += fmt.Sprintf(" --namespace %s", shellQuote(opts.Namespace))
} else {
if !opts.Force {
return fmt.Errorf("txn: cluster-wide txn requires --force (C-23): %w", ErrClusterWideRequiresForce)
}
if !opts.AcknowledgeRisk && !opts.Yes {
return fmt.Errorf("txn: cluster-wide --force requires --i-understand-the-risk (or --yes): %w", ErrClusterWideRequiresAck)
}
cmd += " --force"
if opts.AcknowledgeRisk {
cmd += " --i-understand-the-risk"
}
if opts.Yes {
cmd += " --yes"
}
}
out, err := transport.Exec(ctx, leadPeer, cmd)
if err != nil {
if isExitCode(err, 5) {
slog.Info("txn already applied (no-op)", "txn_id", txnID, "peer", leadPeer)
return ErrAlreadyApplied
}
return fmt.Errorf("txn: apply %s on %s: %w (output: %s)", txnID, leadPeer, err, string(out))
}
slog.Info("txn applied", "txn_id", txnID, "peer", leadPeer, "output", string(out))
return nil
}
func isExitCode(err error, code int) bool {
if err == nil {
return false
}
return errors.Is(err, sshpush.ErrPermanent) && strings.Contains(err.Error(), fmt.Sprintf("exit %d", code))
}
func shellQuote(s string) string {
return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'"
}
func renderApplyScript(id TxnID) []byte {
return []byte(`#!/usr/bin/env bash
# apply.sh — orca txn ` + string(id) + ` (auto-generated; do not edit).
# Idempotently writes the desired state to disk. Re-running after a
# successful apply is a no-op (checks .applied marker).
set -euo pipefail
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
STATE="$DIR/` + fileDesiredState + `"
MARKER="$DIR/` + fileApplied + `"
if [ -f "$MARKER" ]; then
echo "already-applied"
exit 0
fi
if command -v python3 >/dev/null 2>&1; then
python3 - "$STATE" <<'PYEOF'
import json, os, sys
state_path = sys.argv[1]
with open(state_path) as f:
artifacts = json.load(f)
if isinstance(artifacts, dict):
artifacts = [artifacts]
# REQ-121/F5: path allowlist. Only orca-managed paths may be written.
# This prevents a compromised manifest from overwriting arbitrary
# system files (e.g. /etc/shadow, /root/.ssh/authorized_keys).
ALLOWED_PREFIXES = (
"/etc/orca/",
"/etc/traefik/orca",
"/etc/traefik/dynamic/orca",
"/etc/systemd/system/orca-",
"/etc/nftables.d/orca",
"/etc/syncthing/orca",
)
# Resolve symlinks + normalize to catch ../ traversal attempts.
def path_allowed(p):
if not p:
return False
# Reject any path containing .. (path traversal).
if ".." in p.split("/"):
return False
# Reject paths that are not absolute (relative could land anywhere).
if not p.startswith("/"):
return False
norm = os.path.normpath(p)
for prefix in ALLOWED_PREFIXES:
if norm == prefix or norm.startswith(prefix):
return True
return False
for a in artifacts:
path = a.get("path")
if not path:
continue
if not path_allowed(path):
sys.stderr.write("apply: refusing to write disallowed path: %s\n" % path)
sys.exit(7)
content = a.get("content", "")
mode = a.get("mode", "0644")
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w") as fh:
fh.write(content)
try:
m = int(mode, 8)
os.chmod(path, m)
except (ValueError, TypeError):
pass
PYEOF
fi
touch "$MARKER"
echo "applied"
`)
}
func renderVerifyScript(id TxnID) []byte {
return []byte(`#!/usr/bin/env bash
# verify.sh — orca txn ` + string(id) + ` (auto-generated; do not edit).
# Verifies the applied state matches desired-state.json.
set -euo pipefail
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
STATE="$DIR/` + fileDesiredState + `"
if [ ! -f "$STATE" ]; then
echo "verify: missing desired-state" >&2
exit 1
fi
if command -v python3 >/dev/null 2>&1; then
python3 - "$STATE" <<'PYEOF'
import json, os, sys
state_path = sys.argv[1]
with open(state_path) as f:
artifacts = json.load(f)
if isinstance(artifacts, dict):
artifacts = [artifacts]
ok = True
for a in artifacts:
path = a.get("path")
if not path:
continue
want = a.get("content", "")
if not os.path.exists(path):
print("verify: missing %s" % path, file=sys.stderr)
ok = False
continue
with open(path) as fh:
got = fh.read()
if got != want:
print("verify: mismatch %s" % path, file=sys.stderr)
ok = False
sys.exit(0 if ok else 1)
PYEOF
else
echo "verify: python3 missing, cannot verify" >&2
exit 1
fi
echo "verified"
`)
}
func renderRollbackScript(id TxnID) []byte {
return []byte(`#!/usr/bin/env bash
# rollback.sh — orca txn ` + string(id) + ` (auto-generated; do not edit).
# Reverts the apply by removing the .applied marker and the written files.
set -euo pipefail
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
STATE="$DIR/` + fileDesiredState + `"
MARKER="$DIR/` + fileApplied + `"
rm -f "$MARKER"
if command -v python3 >/dev/null 2>&1 && [ -f "$STATE" ]; then
python3 - "$STATE" <<'PYEOF'
import json, os, sys
state_path = sys.argv[1]
with open(state_path) as f:
artifacts = json.load(f)
if isinstance(artifacts, dict):
artifacts = [artifacts]
for a in artifacts:
path = a.get("path")
if not path:
continue
if os.path.exists(path):
try:
os.remove(path)
except OSError:
pass
PYEOF
fi
echo "rolled-back"
`)
}
var _ Transport = (*sshpush.Transport)(nil)