Files
ak47 573d15d7b4 Add Remote session foundations and feasibility checks
Add opt-in Tentacle protocol 1.1, explicit remote grants, lifecycle IPC and expiring core leases in both SDKs. Revoke access before update preparation, including failed updates.

Add transient secret entries, shared Braille cell export and isolated Xorg recovery experiments with focused tests and documented limitations. Production consent, desktop services and live interoperability remain pending.
2026-11-03 22:33:26 -05:00

446 lines
16 KiB
Go

// Copyright (c) 2026 Storm Dragon
// SPDX-License-Identifier: GPL-3.0-or-later
package tentacle
import (
"archive/tar"
"compress/gzip"
"context"
"crypto/sha256"
"debug/elf"
"encoding/hex"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path"
"path/filepath"
"runtime"
"slices"
"strings"
"time"
"github.com/pelletier/go-toml/v2"
"git.stormux.org/storm/cthulhu/internal/tentaclewire"
)
const maxPackageBytes = 32 << 20
const maxExpandedBytes = 64 << 20
func valid_digest(value string) bool {
decoded, err := hex.DecodeString(value)
return err == nil && len(decoded) == sha256.Size && value == strings.ToLower(value)
}
func valid_revision(value string) bool {
decoded, err := hex.DecodeString(value)
return err == nil && (len(decoded) == 20 || len(decoded) == 32) && value == strings.ToLower(value)
}
func artifact_url(value string) bool {
parsed, err := url.Parse(value)
return err == nil && parsed.Scheme == "https" && parsed.Hostname() != "" && parsed.User == nil && parsed.Opaque == "" && parsed.Fragment == "" && !strings.Contains(value, "#") && len(value) <= 2048 && metadata_text(value, true)
}
func validate_releases(releases []Release) error {
if len(releases) > 16 {
return ErrLimit
}
seen := make(map[string]bool)
for _, release := range releases {
key := release.Platform + "/" + release.Architecture
if !metadata_id(release.Platform) || !metadata_id(release.Architecture) || !artifact_url(release.URL) || !valid_digest(release.SHA256) || seen[key] {
return errors.New("Invalid or duplicate package release metadata.")
}
seen[key] = true
}
return nil
}
func PackageRoot(registryPath string) string {
return filepath.Join(filepath.Dir(registryPath), "tentacle-packages")
}
func PackageID(repository Repository, localID string) string {
digest := sha256.Sum256([]byte(repository.URL + "\x00" + repository.ID + "\x00" + localID))
return "pkg-" + hex.EncodeToString(digest[:])
}
// PreparedPackage owns unpublished files until the lifecycle owner persists its
// Entry. Once queued, the caller waits for that owner's decision before Discard;
// successful persistence calls Retain. Validation never runs package code.
type PreparedPackage struct {
Entry Entry
directory string
}
func (prepared *PreparedPackage) Discard() error {
if prepared == nil || prepared.directory == "" {
return nil
}
err := os.RemoveAll(prepared.directory)
if err == nil {
prepared.directory = ""
}
return err
}
func (prepared *PreparedPackage) Retain() { prepared.directory = "" }
func PreparePackage(ctx context.Context, root string, repository Repository, localID string) (*PreparedPackage, error) {
return prepare_package(ctx, root, repository, localID, new_package_client())
}
func new_package_client() *http.Client {
return &http.Client{Timeout: 45 * time.Second, CheckRedirect: func(request *http.Request, via []*http.Request) error {
if len(via) >= 5 || !artifact_url(request.URL.String()) {
return errors.New("Package redirect must use HTTPS without credentials.")
}
return nil
}}
}
func prepare_package(ctx context.Context, root string, repository Repository, localID string, client *http.Client) (_ *PreparedPackage, resultError error) {
if err := ValidateRepositories([]Repository{repository}); err != nil {
return nil, err
}
index := slices.IndexFunc(repository.Tentacles, func(value Manifest) bool { return value.ID == localID })
if index < 0 {
return nil, errors.New("Tentacle is no longer listed in this repository.")
}
manifest := repository.Tentacles[index]
releaseIndex := slices.IndexFunc(manifest.Releases, func(value Release) bool {
return value.Platform == runtime.GOOS && value.Architecture == runtime.GOARCH
})
if releaseIndex < 0 {
return nil, fmt.Errorf("No package is available for %s/%s. Refresh the repository after a release is published.", runtime.GOOS, runtime.GOARCH)
}
release := manifest.Releases[releaseIndex]
if !filepath.IsAbs(root) {
return nil, errors.New("Package root must be absolute.")
}
if err := os.MkdirAll(root, 0700); err != nil {
return nil, err
}
info, err := os.Lstat(root)
if err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
return nil, errors.New("Package root must be a directory, not a symlink.")
}
stage, err := os.MkdirTemp(root, ".staging-")
if err != nil {
return nil, err
}
prepared := &PreparedPackage{directory: stage}
defer func() {
if resultError != nil {
_ = prepared.Discard()
}
}()
request, err := http.NewRequestWithContext(ctx, http.MethodGet, release.URL, nil)
if err != nil {
return nil, err
}
response, err := client.Do(request)
if err != nil {
return nil, errors.New("Package download failed; check the connection or retry.")
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
return nil, errors.New("Package download failed; the server did not return a package.")
}
if response.ContentLength > maxPackageBytes {
return nil, ErrLimit
}
archive, err := os.CreateTemp(root, ".download-")
if err != nil {
return nil, err
}
defer os.Remove(archive.Name())
defer archive.Close()
digest := sha256.New()
count, err := io.Copy(io.MultiWriter(archive, digest), io.LimitReader(response.Body, maxPackageBytes+1))
if err != nil {
return nil, errors.New("Package download was interrupted.")
}
if count > maxPackageBytes {
return nil, ErrLimit
}
if hex.EncodeToString(digest.Sum(nil)) != release.SHA256 {
return nil, errors.New("Package checksum does not match the catalog; nothing was installed.")
}
if _, err := archive.Seek(0, io.SeekStart); err != nil {
return nil, err
}
if err := extract_package(ctx, archive, stage); err != nil {
return nil, err
}
data, err := read_registry(filepath.Join(stage, "tentacle.toml"))
if err != nil || data == nil {
return nil, errors.New("Package must contain a regular tentacle.toml.")
}
var packageManifest struct {
Manifest
Runtime string `toml:"runtime"`
Platforms []string `toml:"platforms"`
EntryPoint []string `toml:"entry-point"`
APIs []string `toml:"apis"`
Protocol struct {
Major int `toml:"major"`
Minor int `toml:"minor"`
} `toml:"protocol"`
}
if err := toml.Unmarshal(data, &packageManifest); err != nil {
return nil, errors.New("Package manifest is invalid TOML.")
}
if packageManifest.FormatVersion != manifest.FormatVersion || packageManifest.ID != manifest.ID || packageManifest.Version != manifest.Version || packageManifest.Name != manifest.Name || !slices.Equal(packageManifest.Capabilities, manifest.Capabilities) {
return nil, errors.New("Package identity, version or permissions disagree with the catalog; refresh the repository.")
}
if packageManifest.Runtime != "native" || !slices.Contains(packageManifest.Platforms, runtime.GOOS) || packageManifest.Protocol.Major != 1 || (packageManifest.Protocol.Minor != 0 && packageManifest.Protocol.Minor != 1) {
return nil, errors.New("Package runtime, platform or protocol is unsupported by this reader.")
}
presentation := slices.Contains(packageManifest.Capabilities, "presentation")
interactive := slices.Contains(packageManifest.Capabilities, "interaction")
readerSettings := slices.Contains(packageManifest.Capabilities, "reader-settings")
expectedAPIs := []string{"commands"}
if presentation {
expectedAPIs = append(expectedAPIs, "presentation")
}
if interactive {
expectedAPIs = append(expectedAPIs, "interaction")
}
if readerSettings {
expectedAPIs = append(expectedAPIs, "reader-settings")
}
valid := false
if packageManifest.Protocol.Minor == 0 {
valid = !(readerSettings && !interactive || interactive && !presentation) && len(packageManifest.Capabilities) == len(expectedAPIs)-1 && slices.Equal(packageManifest.APIs, expectedAPIs)
} else {
valid = tentaclewire.ValidNegotiation(1, packageManifest.APIs, packageManifest.Capabilities)
}
if !valid {
return nil, errors.New("Package requires unsupported APIs or permissions.")
}
if len(packageManifest.EntryPoint) == 0 || len(packageManifest.EntryPoint) > 32 || !package_path(packageManifest.EntryPoint[0]) {
return nil, errors.New("Package entry point must be a relative file path inside the package.")
}
executable := filepath.Join(stage, filepath.FromSlash(packageManifest.EntryPoint[0]))
if err := validate_native_program(executable); err != nil {
return nil, err
}
if err := ctx.Err(); err != nil {
return nil, err
}
if err := filepath.WalkDir(stage, func(filename string, entry os.DirEntry, walkError error) error {
if walkError != nil {
return walkError
}
if entry.IsDir() {
return sync_directory(filename)
}
return nil
}); err != nil {
return nil, err
}
// Every installation gets a private immutable directory. Never overwrite an
// existing version; a crash before registry commit leaves only inert files.
final := filepath.Join(root, "package-"+strings.TrimPrefix(filepath.Base(stage), ".staging-"))
if err := os.Rename(stage, final); err != nil {
return nil, err
}
prepared.directory = final
program := slices.Clone(packageManifest.EntryPoint)
program[0] = filepath.Join(final, filepath.FromSlash(program[0]))
remoteGrants := []string{}
for _, grant := range tentaclewire.RemoteGrants() {
if slices.Contains(packageManifest.Capabilities, grant) {
remoteGrants = append(remoteGrants, grant)
}
}
prepared.Entry = Entry{SessionEvents: packageManifest.Protocol.Minor == 1, RemoteGrants: remoteGrants, ID: PackageID(repository, localID), LocalID: localID, Name: manifest.Name, Version: manifest.Version,
Program: program, Directory: final, Presentation: presentation, Interaction: interactive, ReaderSettings: readerSettings, Repository: repository.URL, RepositoryID: repository.ID,
RepositoryRevision: repository.Revision, PackageDirectory: final, PackageSHA256: release.SHA256}
if err := ValidateEntries([]Entry{prepared.Entry}); err != nil {
return nil, err
}
if err := sync_directory(final); err != nil {
return nil, err
}
if err := sync_directory(root); err != nil {
return nil, err
}
return prepared, nil
}
func package_path(filename string) bool {
return filename != "" && filename != "." && !strings.HasPrefix(filename, "/") && path.Clean(filename) == filename && filename != ".." && !strings.HasPrefix(filename, "../") && !strings.ContainsAny(filename, "\\\x00\r\n:") && len(filename) <= 4096
}
func extract_package(ctx context.Context, archive io.Reader, directory string) error {
compressed, err := gzip.NewReader(archive)
if err != nil {
return errors.New("Package must be a gzip tar archive.")
}
defer compressed.Close()
limited := &io.LimitedReader{R: compressed, N: maxExpandedBytes + 1}
reader := tar.NewReader(limited)
seen := make(map[string]bool)
for count := 0; ; count++ {
if err := ctx.Err(); err != nil {
return err
}
header, err := reader.Next()
if err == io.EOF {
break
}
if err != nil {
return errors.New("Invalid or oversized package archive.")
}
if count >= 1024 || header.Size < 0 || header.Size > maxExpandedBytes {
return ErrLimit
}
filename := strings.TrimSuffix(header.Name, "/")
if !package_path(filename) || seen[filename] {
return errors.New("Package contains unsafe or duplicate paths.")
}
seen[filename] = true
target := filepath.Join(directory, filepath.FromSlash(filename))
switch header.Typeflag {
case tar.TypeDir:
if header.Size != 0 {
return errors.New("Invalid package directory.")
}
if err := os.MkdirAll(target, 0700); err != nil {
return err
}
case tar.TypeReg, tar.TypeRegA:
if err := os.MkdirAll(filepath.Dir(target), 0700); err != nil {
return err
}
mode := os.FileMode(0600)
if header.Mode&0111 != 0 {
mode = 0700
}
file, err := os.OpenFile(target, os.O_WRONLY|os.O_CREATE|os.O_EXCL, mode)
if err != nil {
return errors.New("Package contains conflicting paths.")
}
_, copyError := io.CopyN(file, reader, header.Size)
syncError := file.Sync()
closeError := file.Close()
if err := errors.Join(copyError, syncError, closeError); err != nil {
return err
}
default:
return errors.New("Packages may contain only regular files and directories, without links or devices.")
}
}
// Read through the gzip trailer to detect corruption and expansion bombs.
if _, err := io.Copy(io.Discard, limited); err != nil {
return errors.New("Package compression is corrupt.")
}
if limited.N == 0 {
return ErrLimit
}
return nil
}
func validate_native_program(filename string) error {
info, err := os.Stat(filename)
if err != nil || !info.Mode().IsRegular() || info.Mode()&0100 == 0 {
return errors.New("Package entry point is missing or not executable.")
}
binary, err := elf.Open(filename)
if err != nil {
return errors.New("Native package entry point must be an ELF executable.")
}
defer binary.Close()
machines := map[string]elf.Machine{"amd64": elf.EM_X86_64, "arm64": elf.EM_AARCH64, "386": elf.EM_386, "arm": elf.EM_ARM, "riscv64": elf.EM_RISCV, "ppc64": elf.EM_PPC64, "ppc64le": elf.EM_PPC64, "s390x": elf.EM_S390}
machine, supported := machines[runtime.GOARCH]
if !supported || binary.Machine != machine || (binary.Type != elf.ET_EXEC && binary.Type != elf.ET_DYN) {
return errors.New("Package executable does not match this platform and architecture.")
}
return nil
}
func sync_directory(directory string) error {
file, err := os.Open(directory)
if err != nil {
return err
}
defer file.Close()
return file.Sync()
}
// RemovePackage never deletes explicitly added local programs or paths outside
// the managed root. Call only after a saved removal and after the session joins.
func RemovePackage(root string, entry Entry) error {
directory := entry.PackageDirectory
if directory == "" {
return nil
}
rootInfo, rootError := os.Lstat(root)
if rootError != nil || !rootInfo.IsDir() || rootInfo.Mode()&os.ModeSymlink != 0 {
return errors.New("Managed package root is unsafe.")
}
if filepath.Dir(directory) != filepath.Clean(root) || !strings.HasPrefix(filepath.Base(directory), "package-") || filepath.Clean(directory) != directory {
return errors.New("Refusing to remove a package outside the managed directory.")
}
info, err := os.Lstat(directory)
if errors.Is(err, os.ErrNotExist) {
return nil
}
if err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
return errors.New("Installed package directory is unsafe.")
}
if err := os.RemoveAll(directory); err != nil {
return err
}
return sync_directory(root)
}
// RecoverPackages runs once, after a successful registry load, before accepting
// management requests. The registry is the commit point; orphaned directories
// from interrupted installs/removals contain no live selected code.
func RecoverPackages(root string, entries []Entry) error {
info, err := os.Lstat(root)
if errors.Is(err, os.ErrNotExist) {
return nil
}
if err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
return errors.New("Managed package root is unsafe.")
}
retained := make(map[string]bool)
for _, entry := range entries {
for _, filename := range append([]string{entry.PackageDirectory, entry.Directory}, entry.Program...) {
relative, err := filepath.Rel(root, filename)
if err == nil && package_path(filepath.ToSlash(relative)) {
retained[strings.Split(filepath.ToSlash(relative), "/")[0]] = true
}
}
}
files, err := os.ReadDir(root)
if err != nil {
return err
}
for _, file := range files {
name := file.Name()
owned := false
for _, prefix := range []string{".staging-", ".download-", "package-"} {
if suffix, found := strings.CutPrefix(name, prefix); found && suffix != "" && len(suffix) <= 10 && !strings.ContainsFunc(suffix, func(value rune) bool { return value < '0' || value > '9' }) {
owned = true
}
}
if !owned || retained[name] || file.Type()&os.ModeSymlink != 0 {
continue
}
if err := os.RemoveAll(filepath.Join(root, name)); err != nil {
return err
}
}
return sync_directory(root)
}