unarr/internal/funnel/install.go
Deivid Soto 3d51013935 fix(agent): surface par2/install/NFS failures instead of degrading silently
- usenet: Par2Verify/Repair return ErrPar2NotInstalled (was nil="verified");
  pipeline surfaces it via Result.VerifyNote + WARNING — a download that
  shipped parity but couldn't be checked is delivered UNVERIFIED, not verified.
- funnel: pin cloudflared version + verify a baked-in SHA-256 (was `latest` +
  ELF-magic only) — a malicious/broken upstream release isn't pulled silently.
- stream: makeReadable verifies the file actually opens after chmod and warns
  clearly (NFS root_squash / SMB uid mapping) instead of a cryptic later EPERM.
- WireGuard endpoint pin dropped from the debt list (reseller uses direct
  config, no pin).
2026-06-01 15:52:54 +02:00

224 lines
7.8 KiB
Go

package funnel
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"time"
"github.com/torrentclaw/unarr/internal/config"
)
// pinnedCloudflaredVersion is the exact cloudflared release the auto-downloader
// fetches. We deliberately do NOT track `latest`: pinning a version we vetted +
// verifying its SHA-256 is what bounds the supply-chain risk (a future malicious
// or breaking upstream release can't be pulled silently). Operator-installed
// cloudflared on $PATH always wins, so this only affects the headless
// auto-download fallback.
//
// To bump: pick a newer tag, copy its per-asset SHA-256 from the release body
// (https://github.com/cloudflare/cloudflared/releases/tag/<version>) into the
// map below, and update this constant. All four arch entries MUST be present.
const pinnedCloudflaredVersion = "2026.5.2"
// pinnedCloudflaredSHA256 maps each linux asset to its SHA-256 for
// pinnedCloudflaredVersion (from the release body — Cloudflare publishes the
// hashes inline there, not as a separate file or signature).
var pinnedCloudflaredSHA256 = map[string]string{
"cloudflared-linux-amd64": "5286698547f03df745adb2355f04c12dde52ef425491e81f433642d695521886",
"cloudflared-linux-arm64": "5a4e8ce2701105271412059f44b6a0bf1ae4542b4d98ff3180c0c019443a5815",
"cloudflared-linux-armhf": "190152c373f608080eb6aa9e2aad395f88398dfb9efd0f9b064e2652cffcefdd",
"cloudflared-linux-386": "ad82d1dbed8bbb9d702807cbd97df932cc774d29e9da5c109b7a3c7f7aee2065",
}
// ResolveBinary returns the path to a usable cloudflared executable, downloading
// one into the unarr data dir if neither $PATH nor the cached location has it.
// This makes the funnel feature usable on headless installs (NAS / Docker)
// where the user can't easily install cloudflared via the OS package manager.
//
// Resolution order:
//
// 1. cloudflared on $PATH (operator already installed it)
// 2. <data-dir>/bin/cloudflared (we cached it on a previous run)
// 3. download from GitHub releases (Linux-only fallback; macOS / Windows
// return a clear error pointing at brew / winget)
func ResolveBinary() (string, error) {
if p, err := exec.LookPath("cloudflared"); err == nil {
return p, nil
}
cached := cachedBinaryPath()
if _, err := os.Stat(cached); err == nil {
return cached, nil
}
return downloadCloudflared(cached)
}
func cachedBinaryPath() string {
name := "cloudflared"
if runtime.GOOS == "windows" {
name += ".exe"
}
return filepath.Join(config.DataDir(), "bin", name)
}
// downloadCloudflared fetches the PINNED cloudflared release asset matching the
// current GOOS/GOARCH into `dest`. Linux only — macOS/Windows return a pointer
// at the OS package manager.
//
// Integrity: the fetch is HTTPS (bounded by Let's Encrypt + GitHub's cert
// chain) AND the downloaded bytes are verified against a baked-in SHA-256 for
// the pinned version (pinnedCloudflaredSHA256). A mismatch — corruption, MITM
// past TLS, a swapped asset — is rejected before the binary is promoted or run.
// Because the version is pinned (not `latest`), a future malicious/breaking
// upstream release is never pulled silently. The cheap ELF/size check still
// runs first to reject a 404 HTML page before hashing 50 MB. For stricter
// control, install cloudflared via your distro package manager — the PATH copy
// always takes precedence.
func downloadCloudflared(dest string) (string, error) {
if runtime.GOOS != "linux" {
return "", fmt.Errorf("funnel: auto-download not supported on %s — install cloudflared manually or drop a binary at %s", runtime.GOOS, dest)
}
var asset string
switch runtime.GOARCH {
case "amd64":
asset = "cloudflared-linux-amd64"
case "arm64":
asset = "cloudflared-linux-arm64"
case "arm":
asset = "cloudflared-linux-armhf"
case "386":
asset = "cloudflared-linux-386"
default:
return "", fmt.Errorf("funnel: unsupported linux arch %q — install cloudflared manually", runtime.GOARCH)
}
expectedSHA, ok := pinnedCloudflaredSHA256[asset]
if !ok {
return "", fmt.Errorf("funnel: no pinned SHA-256 for asset %q (bug: keep pinnedCloudflaredSHA256 in sync with the arch switch)", asset)
}
url := "https://github.com/cloudflare/cloudflared/releases/download/" + pinnedCloudflaredVersion + "/" + asset
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
return "", fmt.Errorf("funnel: create bin dir: %w", err)
}
// O_EXCL so concurrent unarr-dev / prod daemons don't clobber each
// other's partial download. The loser gets EEXIST → falls back to
// polling for the winner to finish.
tmp := dest + ".partial"
out, err := os.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o755)
if err != nil {
if errors.Is(err, os.ErrExist) {
// Another process is downloading. Wait briefly for them to finish.
for range 60 {
time.Sleep(time.Second)
if _, statErr := os.Stat(dest); statErr == nil {
return dest, nil
}
}
return "", fmt.Errorf("funnel: another download in progress at %s (timed out)", tmp)
}
return "", fmt.Errorf("funnel: open dest: %w", err)
}
client := &http.Client{Timeout: 5 * time.Minute}
resp, err := client.Get(url)
if err != nil {
_ = out.Close()
_ = os.Remove(tmp)
return "", fmt.Errorf("funnel: download cloudflared: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
_ = out.Close()
_ = os.Remove(tmp)
return "", fmt.Errorf("funnel: download cloudflared: HTTP %d from %s", resp.StatusCode, url)
}
if _, err := io.Copy(out, resp.Body); err != nil {
_ = out.Close()
_ = os.Remove(tmp)
return "", fmt.Errorf("funnel: write dest: %w", err)
}
if err := out.Close(); err != nil {
_ = os.Remove(tmp)
return "", fmt.Errorf("funnel: close dest: %w", err)
}
// Cheap reject first: must be a Linux ELF executable (rejects 404 HTML pages
// or wrong-arch payloads) and at least 1 MB, so we don't hash 50 MB of an
// obviously-wrong file.
if err := verifyLinuxElf(tmp); err != nil {
_ = os.Remove(tmp)
return "", fmt.Errorf("funnel: downloaded file failed sanity check: %w", err)
}
// Authoritative integrity gate: the bytes must match the SHA-256 we baked in
// for the pinned version. Rejects corruption, a MITM past TLS, or a swapped
// asset before the binary is ever promoted or executed.
if err := verifySHA256(tmp, expectedSHA); err != nil {
_ = os.Remove(tmp)
return "", fmt.Errorf("funnel: cloudflared %s integrity check failed: %w", pinnedCloudflaredVersion, err)
}
if err := os.Rename(tmp, dest); err != nil {
_ = os.Remove(tmp)
return "", fmt.Errorf("funnel: rename dest: %w", err)
}
return dest, nil
}
// verifyLinuxElf returns nil when the file at `path` starts with the ELF
// magic bytes and is at least 1 MB. Used as a low-cost guard against
// downloading an HTML error page or a wrong-arch payload.
func verifyLinuxElf(path string) error {
st, err := os.Stat(path)
if err != nil {
return err
}
if st.Size() < 1024*1024 {
return errors.New("file is suspiciously small (<1 MB)")
}
f, err := os.Open(path)
if err != nil {
return err
}
defer f.Close()
head := make([]byte, 4)
if _, err := io.ReadFull(f, head); err != nil {
return fmt.Errorf("read magic bytes: %w", err)
}
if !bytes.Equal(head, []byte{0x7f, 'E', 'L', 'F'}) {
return errors.New("not an ELF binary")
}
return nil
}
// verifySHA256 returns nil when the file at `path` hashes to expectedHex
// (case-insensitive), else an error reporting both digests.
func verifySHA256(path, expectedHex string) error {
f, err := os.Open(path)
if err != nil {
return err
}
defer f.Close()
h := sha256.New()
if _, err := io.Copy(h, f); err != nil {
return fmt.Errorf("hashing: %w", err)
}
got := hex.EncodeToString(h.Sum(nil))
if !strings.EqualFold(got, expectedHex) {
return fmt.Errorf("sha256 mismatch: got %s, want %s", got, expectedHex)
}
return nil
}