towerops-agent/update.go
Graham McIntire 442b019b0b
security: sanitize token from re-exec log line
os.Args logged during self-update re-exec could expose --token value
in plaintext to log aggregators. sanitizeArgs masks the value.
2026-02-12 10:59:08 -06:00

98 lines
2.7 KiB
Go

package main
import (
"crypto/sha256"
"crypto/subtle"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"os"
"strings"
"syscall"
)
var osExecutable = os.Executable
var osWriteFile = os.WriteFile
var osRename = os.Rename
var httpGet = http.Get
var maxUpdateSize int64 = 100 << 20 // 100 MB
// selfUpdate downloads a new binary, verifies its checksum, replaces the current binary, and re-execs.
func selfUpdate(downloadURL, expectedChecksum string) error {
u, err := url.Parse(downloadURL)
if err != nil {
return fmt.Errorf("parse url: %w", err)
}
if u.Scheme != "https" {
return fmt.Errorf("HTTPS required for update URL, got %q", u.Scheme)
}
if expectedChecksum == "" {
return fmt.Errorf("checksum required for update")
}
slog.Info("downloading update", "url", downloadURL)
resp, err := httpGet(downloadURL)
if err != nil {
return fmt.Errorf("download: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("download failed: status %d", resp.StatusCode)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, maxUpdateSize+1))
if err != nil {
return fmt.Errorf("read body: %w", err)
}
if int64(len(body)) > maxUpdateSize {
return fmt.Errorf("download size %d exceeds max %d", len(body), maxUpdateSize)
}
slog.Info("downloaded update", "bytes", len(body))
// Verify SHA256 checksum (constant-time comparison)
actual := fmt.Sprintf("%x", sha256.Sum256(body))
if subtle.ConstantTimeCompare([]byte(actual), []byte(expectedChecksum)) != 1 {
return fmt.Errorf("checksum mismatch: expected %s, got %s", expectedChecksum, actual)
}
slog.Info("checksum verified")
// Write to temp file next to current binary
currentExe, err := osExecutable()
if err != nil {
return fmt.Errorf("get executable path: %w", err)
}
tempPath := currentExe + ".update"
if err := osWriteFile(tempPath, body, 0700); err != nil {
return fmt.Errorf("write temp: %w", err)
}
// Replace current binary
if err := osRename(tempPath, currentExe); err != nil {
_ = os.Remove(tempPath)
return fmt.Errorf("rename: %w", err)
}
slog.Info("binary replaced", "path", currentExe)
// Re-exec with same arguments
slog.Info("re-executing", "args", sanitizeArgs(os.Args))
return syscall.Exec(currentExe, os.Args, os.Environ())
}
// sanitizeArgs returns a copy of args with token values masked.
func sanitizeArgs(args []string) []string {
out := make([]string, len(args))
copy(out, args)
for i, a := range out {
if (a == "--token" || a == "-token") && i+1 < len(out) {
out[i+1] = "***"
} else if strings.HasPrefix(a, "--token=") || strings.HasPrefix(a, "-token=") {
out[i] = a[:strings.Index(a, "=")+1] + "***"
}
}
return out
}