// 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 }