dfacfea377
---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.
465 lines
13 KiB
Go
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)
|