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>
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
Reference in New Issue
Block a user