diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..74377fc --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,36 @@ +name: hermetic-ci + +on: + push: + pull_request: + +permissions: + contents: read + +env: + GOFLAGS: -mod=readonly + GOTOOLCHAIN: local + +jobs: + test: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: false + - name: Download locked modules + run: go mod download + - name: Formatting + run: test -z "$(gofmt -l .)" + - name: Hermetic full test suite + run: ./scripts/test-hermetic.sh + - name: Static analysis + run: go vet ./... + - name: Documentation contract + run: ./scripts/check-docs.sh + - name: Reproducible cross-builds and checksums + run: ./scripts/test-release.sh + - name: Clean-machine verification + run: ./scripts/verify-clean-machine.sh diff --git a/README.md b/README.md new file mode 100644 index 0000000..2c37a08 --- /dev/null +++ b/README.md @@ -0,0 +1,135 @@ +# proxyctl + +`proxyctl` is a keyboard-driven Linux TUI for coordinating one **Active Profile** across independently switchable proxy **Targets**. It manages proxy configuration surfaces; it does not run a proxy server. + +## Supported systems + +The v1 binaries target Linux on amd64 and arm64. General TUI behavior and the Shell Target are distribution-independent. The supported Target boundaries are: + +- **Shell:** Bash and Zsh only. +- **APT:** Debian-family systems with `apt-config` and `apt.conf.d`. +- **Docker daemon:** Docker installations managed by systemd. +- **Docker containers/builds:** the standard per-user Docker client `config.json` model. +- **GNOME:** sessions exposing the expected `org.gnome.system.proxy` settings schemas. + +Unavailable Targets are hidden from the main panel; `?` shows every built-in Target and its detection evidence. + +## Install + +### Verified release binary + +Download the binary for your architecture together with `SHA256SUMS`, then verify it before installing: + +```sh +grep 'proxyctl-linux-amd64$' SHA256SUMS | sha256sum -c - # substitute arm64 when needed +chmod +x proxyctl-linux-amd64 # use proxyctl-linux-arm64 on arm64 +install -m 0755 proxyctl-linux-amd64 "$HOME/.local/bin/proxyctl" +proxyctl version +proxyctl version --short # exact value only, for scripts +``` + +Release output is two single binaries, not packages or archives. The project does not publish deb, rpm, AUR, Snap, Flatpak, or similar packages. + +### Install from source with Go + +Go 1.24 or newer is required. From a source checkout: + +```sh +go install ./cmd/proxyctl +``` + +The executable is placed in `GOBIN`, or in `$(go env GOPATH)/bin` when `GOBIN` is unset. Ensure that directory is on `PATH`. Source builds report `dev` unless a release version is injected by release automation. + +## First use and Profiles + +Start the Target panel with no arguments: + +```sh +proxyctl +``` + +Press `p` for Proxy Profile management, `n` to create a Profile, enter a name and proxy URL, and press Ctrl+S to save. Single mode applies one URL to HTTP, HTTPS, and ALL/SOCKS. Ctrl+T selects advanced mode for separate values. Bypass accepts hostnames, domain suffixes, IPv4, IPv6, and CIDR entries. Select the Profile and press `a` or Enter to make it the Active Profile. + +The main panel shows the Active Profile. Use arrows or `j`/`k` to select an Available Target, Space to toggle it, `r` to refresh detection, `?` for details, and `q` or Escape to leave. A Target cannot be enabled without an Active Profile, but previously managed configuration can still be disabled. + +Profiles are human-readable under the user's XDG configuration directory (normally `~/.config/proxyctl/profiles.json`) and are stored owner-only because proxy URLs may contain credentials. + +## Shell integration + +A child TUI cannot change its parent Shell. Install the one-time Bash/Zsh startup hook: + +```sh +proxyctl shell install +``` + +Restart or source the startup file once. Thereafter the wrapper reloads proxy exports and unsets after `proxyctl` exits. The Shell Target emits conventional upper- and lower-case `HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY`, and `NO_PROXY` forms. Remove the hook with: + +```sh +proxyctl shell uninstall +``` + +Fish and other Shells are intentionally unsupported in v1. + +## What each Target affects + +1. **Shell** — commands started by integrated Bash/Zsh sessions. It does not change existing processes, system services, Docker, or desktop settings. +2. **APT** — persistent APT package operations across Shell and privilege boundaries through a tool-owned `apt.conf.d` overlay. It does not configure other package managers and requires no service restart. +3. **Docker daemon** — daemon operations such as registry access. It does not set container networking or container/build defaults. Every change reloads systemd and immediately restarts Docker; confirm only when disruption to running work is acceptable. +4. **Docker containers/builds** — proxy variables and build arguments for future containers and builds via the owning user's Docker client configuration. It does not affect existing containers, the Docker CLI connection, or the daemon. +5. **GNOME** — GNOME system proxy settings used by compatible desktop applications. It does not rewrite browser-specific profiles or configure non-GNOME desktops. + +proxyctl prefers isolated, tool-owned overlays. Where that is impossible it records original values and a last-applied fingerprint so disable/reset can restore prior state safely. + +## Target states + +- **Enabled:** actual managed configuration matches the Active Profile. +- **Disabled:** no managed or positively recognized external proxy configuration was detected. +- **External:** a recognized proxy configuration exists but is not owned by proxyctl. +- **Drifted:** managed configuration exists but no longer matches the Active Profile, including partial failures or configuration left when the final Profile is deleted. + +External detection is best-effort; Disabled does not claim that every possible configuration source was exhaustively searched. + +## Permissions, sudo, and restarts + +User Targets normally need only user-file permissions. APT and Docker daemon files generally require authority to write system locations, and Docker daemon operations require authority to reload/restart its systemd service. + +proxyctl **never** runs `sudo`, `pkexec`, or another elevation mechanism. Permission failures are reported normally. If system Targets are wanted, the user must explicitly start `sudo proxyctl`; under sudo, the invoking user remains the owner of Profiles, Shell, Docker client, and GNOME configuration while current root authority is available to system Targets. In a direct root login, root is the owner. Review the Docker restart warning before confirming any daemon change. + +## Diagnose, reset, and remove + +Run read-only diagnostics for Profile validity, storage permissions, required commands, Target availability/state, and Target-specific authority: + +```sh +proxyctl doctor +``` + +Before removal, restore every managed Target. The command asks for confirmation and does nothing when declined: + +```sh +proxyctl reset +proxyctl shell uninstall +``` + +Inspect failures and rerun until reset reports no failed managed Target. Then remove the installed executable and, after retaining any desired logs, remove empty proxyctl configuration/state directories. Do not delete recovery state before a successful reset because it may be needed to restore original settings. + +## Privacy and safety boundaries + +proxyctl has no telemetry, automatic update checks, or automatic proxy-connectivity tests. It does not contact the network on its own. Local diagnostic logs are credential-redacted and bounded in size. Proxy credentials remain present where a selected Target requires them, so normal local file security still matters. + +proxyctl does **not** manage or supervise proxy processes such as Clash, sing-box, or Xray. It does not manage subscriptions, nodes, latency, routing rules, transparent proxying, TUN/iptables/nftables, arbitrary services or commands, plugins, or a complete headless Profile/Target CLI. + +Deferred integrations include DNF, Git-specific settings, npm/pip, arbitrary systemd services, Firefox policies, Chromium launch flags, KDE and other desktops, Snap/Ubuntu Core, Fish, Windows, macOS, distribution packages, mouse interaction, and cross-Target rollback. Conventional Shell variables may already cover some deferred command-line tools. + +Support is limited to the Linux architectures and Target boundaries above. When reporting an issue, include `proxyctl version`, redacted `proxyctl doctor` output, distribution/desktop/Shell details, and the affected Target/state. Never include unredacted proxy credentials. + +## Build and verification + +Create reproducible Linux binaries and checksums without packages or archives: + +```sh +VERSION=v1.0.0 ./scripts/build-release.sh +sha256sum -c dist/SHA256SUMS +file dist/proxyctl-linux-amd64 dist/proxyctl-linux-arm64 +``` + +`./scripts/test-release.sh` builds twice, compares binaries and checksum manifests, verifies both checksums, and checks ELF architectures. `./scripts/verify-clean-machine.sh` uses an isolated identity and XDG roots to exercise `go install`, Profile and hermetic Target workflows, doctor, reset cancellation, Shell install/uninstall, removal, and unchanged host-state snapshots. diff --git a/cmd/proxyctl/main.go b/cmd/proxyctl/main.go index 44c3b81..d22ee25 100644 --- a/cmd/proxyctl/main.go +++ b/cmd/proxyctl/main.go @@ -27,6 +27,9 @@ import ( "proxyctl/internal/tui" ) +// version is overridden for releases with -ldflags "-X main.version=". +var version = "dev" + func main() { if err := run(os.Args[1:], os.Stdin, os.Stdout); err != nil { fmt.Fprintln(os.Stderr, "proxyctl:", err) @@ -50,11 +53,17 @@ func runWithSystemTargets(args []string, input io.Reader, output io.Writer, syst type processEnvironment struct{} +var ( + processCurrentUser = profile.SystemCurrentUser + processLookupUser = profile.SystemLookupUser + processProductionTargets = productionTargets +) + func (processEnvironment) CurrentUser() (profile.UserIdentity, error) { - return profile.SystemCurrentUser() + return processCurrentUser() } func (processEnvironment) LookupUser(name string) (profile.UserIdentity, error) { - return profile.SystemLookupUser(name) + return processLookupUser(name) } func (processEnvironment) LookupEnv(name string) (string, bool) { return os.LookupEnv(name) } @@ -63,6 +72,15 @@ func runWithEnvironment(args []string, input io.Reader, output io.Writer, enviro } func runWithEnvironmentAndTargets(args []string, input io.Reader, output io.Writer, environment profile.IdentityEnvironment, files shell.FileSystem, executable string, systemTargets []target.Target) error { + if len(args) == 1 && args[0] == "version" { + fmt.Fprintf(output, "proxyctl version %s\n", version) + return nil + } + if len(args) == 2 && args[0] == "version" && args[1] == "--short" { + fmt.Fprintln(output, version) + return nil + } + profileConfiguration, err := profile.ResolveConfiguration(environment) if err != nil { if len(args) == 1 && args[0] == "doctor" { @@ -79,7 +97,7 @@ func runWithEnvironmentAndTargets(args []string, input io.Reader, output io.Writ } shellTarget := shell.New(shellConfiguration, files, environment) if systemTargets == nil { - systemTargets, err = productionTargets(environment) + systemTargets, err = processProductionTargets(environment) if err != nil { return err } @@ -129,7 +147,7 @@ func runWithEnvironmentAndTargets(args []string, input io.Reader, output io.Writ case len(args) == 1 && args[0] == "reset": return runMaintenanceCommand(args, input, output, store, targets, mutationLock, logger, nil) default: - return fmt.Errorf("usage: proxyctl [doctor|reset|shell install|shell uninstall]") + return fmt.Errorf("usage: proxyctl [version [--short]|doctor|reset|shell install|shell uninstall]") } } diff --git a/cmd/proxyctl/main_test.go b/cmd/proxyctl/main_test.go index 06f7c71..cdef224 100644 --- a/cmd/proxyctl/main_test.go +++ b/cmd/proxyctl/main_test.go @@ -11,6 +11,35 @@ import ( "proxyctl/internal/target" ) +func TestVersionOutputIsHumanAndScriptFriendly(t *testing.T) { + previous := version + version = "v1.2.3" + t.Cleanup(func() { version = previous }) + + var output bytes.Buffer + if err := run([]string{"version"}, strings.NewReader(""), &output); err != nil { + t.Fatalf("run version: %v", err) + } + if got, want := output.String(), "proxyctl version v1.2.3\n"; got != want { + t.Fatalf("human version output = %q, want %q", got, want) + } + + output.Reset() + if err := run([]string{"version", "--short"}, strings.NewReader(""), &output); err != nil { + t.Fatalf("run short version: %v", err) + } + if got, want := output.String(), "v1.2.3\n"; got != want { + t.Fatalf("script version output = %q, want %q", got, want) + } +} + +func TestVersionDoesNotRequireUserOrTargetResolution(t *testing.T) { + var output bytes.Buffer + if err := runWithEnvironmentAndTargets([]string{"version", "--short"}, strings.NewReader(""), &output, failingIdentityEnvironment{}, nil, "", nil); err != nil { + t.Fatalf("version depended on runtime environment: %v", err) + } +} + func TestProductionCompositionRegistersAPTContainerGNOMEAndDaemonTargets(t *testing.T) { home := t.TempDir() environment := commandEnvironment{identity: profile.UserIdentity{Username: "tester", HomeDir: home, UID: os.Getuid(), GID: os.Getgid()}, env: map[string]string{"SHELL": "/bin/bash"}} diff --git a/cmd/proxyctl/maintenance_test.go b/cmd/proxyctl/maintenance_test.go index df7140a..3b9393f 100644 --- a/cmd/proxyctl/maintenance_test.go +++ b/cmd/proxyctl/maintenance_test.go @@ -116,39 +116,75 @@ func TestDoctorStoragePermissionCheckIsReadOnly(t *testing.T) { } } -func TestMaintenanceCommandsRunThroughBuiltBinaryWithIsolatedXDG(t *testing.T) { +func TestCommandsRunAsRealProcessesInsideIsolatedUserState(t *testing.T) { root := t.TempDir() - binary := filepath.Join(root, "proxyctl") - build := exec.Command("go", "build", "-o", binary, ".") + binary := filepath.Join(root, "bin", "proxyctl") + if err := os.MkdirAll(filepath.Dir(binary), 0o700); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(root, "home"), 0o700); err != nil { + t.Fatal(err) + } + build := exec.Command("go", "build", "-tags", "proxyctl_process_test", "-o", binary, ".") if output, err := build.CombinedOutput(); err != nil { t.Fatalf("build proxyctl: %v\n%s", err, output) } environment := isolatedProcessEnvironment(root) - doctor := exec.Command(binary, "doctor") - doctor.Env = environment - doctorOutput, doctorErr := doctor.CombinedOutput() - if doctorErr == nil || !strings.Contains(string(doctorOutput), "[FAIL]") || !strings.Contains(string(doctorOutput), "Target availability") { + invoke := func(input string, args ...string) ([]byte, error) { + t.Helper() + command := exec.Command(binary, args...) + command.Env = environment + command.Stdin = strings.NewReader(input) + return command.CombinedOutput() + } + + defaultOutput, err := invoke("q") + if err != nil || !strings.Contains(string(defaultOutput), "proxyctl — Target panel") { + t.Fatalf("default process err=%v output=%q", err, defaultOutput) + } + versionOutput, err := invoke("", "version", "--short") + if err != nil || strings.TrimSpace(string(versionOutput)) == "" || strings.Contains(string(versionOutput), " ") { + t.Fatalf("version process err=%v output=%q", err, versionOutput) + } + doctorOutput, doctorErr := invoke("", "doctor") + if !strings.Contains(string(doctorOutput), "Profile validity") || (doctorErr != nil && !strings.Contains(string(doctorOutput), "[FAIL]")) { t.Fatalf("doctor process err=%v output=%q", doctorErr, doctorOutput) } - - cancel := exec.Command(binary, "reset") - cancel.Env = environment - cancel.Stdin = strings.NewReader("no\n") - cancelOutput, err := cancel.CombinedOutput() - if err != nil || !strings.Contains(string(cancelOutput), "Reset cancelled") { - t.Fatalf("cancel process err=%v output=%q", err, cancelOutput) + resetOutput, err := invoke("no\n", "reset") + if err != nil || !strings.Contains(string(resetOutput), "Reset cancelled") { + t.Fatalf("reset cancellation process err=%v output=%q", err, resetOutput) + } + resetOutput, err = invoke("yes\n", "reset") + if err != nil || !strings.Contains(string(resetOutput), "shell: skipped (Disabled)") { + t.Fatalf("confirmed reset process err=%v output=%q", err, resetOutput) + } + installOutput, err := invoke("", "shell", "install") + if err != nil || !strings.Contains(string(installOutput), "installed") { + t.Fatalf("shell install process err=%v output=%q", err, installOutput) + } + startup := filepath.Join(root, "home", ".bashrc") + startupData, err := os.ReadFile(startup) + if err != nil || !strings.Contains(string(startupData), "proxyctl shell integration") { + t.Fatalf("isolated startup after install err=%v data=%q", err, startupData) + } + uninstallOutput, err := invoke("", "shell", "uninstall") + if err != nil || !strings.Contains(string(uninstallOutput), "uninstalled") { + t.Fatalf("shell uninstall process err=%v output=%q", err, uninstallOutput) + } + startupData, err = os.ReadFile(startup) + if err != nil || strings.Contains(string(startupData), "proxyctl shell integration") { + t.Fatalf("isolated startup after uninstall err=%v data=%q", err, startupData) } - confirmed := exec.Command(binary, "reset") - confirmed.Env = environment - confirmed.Stdin = strings.NewReader("yes\n") - confirmedOutput, err := confirmed.CombinedOutput() - if err != nil || !strings.Contains(string(confirmedOutput), "shell: skipped (Disabled)") { - t.Fatalf("confirmed process err=%v output=%q", err, confirmedOutput) - } - if _, err := os.Stat(filepath.Join(root, "home", ".bashrc")); !os.IsNotExist(err) { - t.Fatalf("process smoke test touched a Shell startup file: %v", err) + for _, path := range []string{ + filepath.Join(root, "etc"), + filepath.Join(root, "var"), + filepath.Join(root, "home", ".docker"), + } { + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatalf("process smoke created prohibited system/Docker fixture %s: %v", path, err) + } } } @@ -165,7 +201,8 @@ func isolatedProcessEnvironment(root string) []string { "HOME="+filepath.Join(root, "home"), "XDG_CONFIG_HOME="+filepath.Join(root, "config"), "XDG_STATE_HOME="+filepath.Join(root, "state"), - "SHELL=/bin/fish", + "SHELL=/bin/bash", + "PROXYCTL_PROCESS_TEST_HOME="+filepath.Join(root, "home"), ) } diff --git a/cmd/proxyctl/process_testenv.go b/cmd/proxyctl/process_testenv.go new file mode 100644 index 0000000..3e5f493 --- /dev/null +++ b/cmd/proxyctl/process_testenv.go @@ -0,0 +1,41 @@ +//go:build proxyctl_process_test + +package main + +import ( + "errors" + "os" + "os/user" + "strconv" + + "proxyctl/internal/profile" + "proxyctl/internal/target" +) + +// This file is compiled only into the process-smoke binary. It gives that real +// executable an identity whose home is inside the test sandbox and omits +// production system Targets, so no command can reach developer or host state. +func init() { + processProductionTargets = func(profile.IdentityEnvironment) ([]target.Target, error) { + return []target.Target{}, nil + } + processCurrentUser = func() (profile.UserIdentity, error) { + home := os.Getenv("PROXYCTL_PROCESS_TEST_HOME") + if home == "" { + return profile.UserIdentity{}, errors.New("PROXYCTL_PROCESS_TEST_HOME is required by proxyctl_process_test builds") + } + current, err := user.Current() + if err != nil { + return profile.UserIdentity{}, err + } + uid, err := strconv.Atoi(current.Uid) + if err != nil { + return profile.UserIdentity{}, err + } + gid, err := strconv.Atoi(current.Gid) + if err != nil { + return profile.UserIdentity{}, err + } + return profile.UserIdentity{Username: current.Username, HomeDir: home, UID: uid, GID: gid}, nil + } +} diff --git a/scripts/build-release.sh b/scripts/build-release.sh new file mode 100755 index 0000000..405b4e0 --- /dev/null +++ b/scripts/build-release.sh @@ -0,0 +1,39 @@ +#!/bin/sh +set -eu + +repo=$(CDPATH= cd -- "$(dirname "$0")/.." && pwd) +out=${1:-"$repo/dist"} +: "${VERSION:?set VERSION to the release version, for example v1.0.0}" + +case $VERSION in + *[!A-Za-z0-9._+-]*|'') + echo "VERSION contains unsupported characters: $VERSION" >&2 + exit 2 + ;; +esac + +mkdir -p "$out" +out=$(CDPATH= cd -- "$out" && pwd) +rm -f "$out/proxyctl-linux-amd64" "$out/proxyctl-linux-arm64" "$out/SHA256SUMS" + +build() { + arch=$1 + output="$out/proxyctl-linux-$arch" + ( + cd "$repo" + CGO_ENABLED=0 GOOS=linux GOARCH="$arch" \ + go build -mod=readonly -buildvcs=false -trimpath \ + -ldflags="-s -w -X main.version=$VERSION" \ + -o "$output" ./cmd/proxyctl + ) +} + +umask 022 +build amd64 +build arm64 +( + cd "$out" + sha256sum proxyctl-linux-amd64 proxyctl-linux-arm64 > SHA256SUMS +) + +printf 'built Linux amd64 and arm64 binaries in %s\n' "$out" diff --git a/scripts/check-docs.sh b/scripts/check-docs.sh new file mode 100755 index 0000000..1703efe --- /dev/null +++ b/scripts/check-docs.sh @@ -0,0 +1,23 @@ +#!/bin/sh +set -eu + +repo=$(CDPATH= cd -- "$(dirname "$0")/.." && pwd) +readme="$repo/README.md" + +test -s "$readme" +for text in \ + '## Install' 'go install ./cmd/proxyctl' '## First use and Profiles' \ + '## Shell integration' 'Shell' 'APT' 'Docker daemon' \ + 'Docker containers/builds' 'GNOME' '## Target states' 'Enabled' \ + 'Disabled' 'External' 'Drifted' '## Permissions, sudo, and restarts' \ + 'proxyctl doctor' 'proxyctl reset' 'proxyctl shell uninstall' \ + 'no telemetry' 'automatic update checks' 'proxy-connectivity tests' \ + 'does **not** manage or supervise proxy processes' 'Support is limited' +do + grep -Fq "$text" "$readme" || { + echo "README is missing required documentation: $text" >&2 + exit 1 + } +done + +printf 'README documentation contract passed\n' diff --git a/scripts/test-hermetic.sh b/scripts/test-hermetic.sh new file mode 100755 index 0000000..2c66b83 --- /dev/null +++ b/scripts/test-hermetic.sh @@ -0,0 +1,43 @@ +#!/bin/sh +set -eu + +repo=$(CDPATH= cd -- "$(dirname "$0")/.." && pwd) +work=$(mktemp -d) +trap 'rm -rf "$work"' EXIT HUP INT TERM + +snapshot() { + for path in \ + "$HOME/.bashrc" "$HOME/.zshrc" "$HOME/.docker/config.json" \ + /etc/apt/apt.conf.d /etc/docker/daemon.json /var/lib/proxyctl \ + /etc/systemd/system/docker.service.d + do + if [ -d "$path" ]; then + find "$path" -type f -print0 2>/dev/null | sort -z | xargs -0 -r sha256sum 2>/dev/null || ls -ld "$path" + elif [ -r "$path" ]; then + sha256sum "$path" + elif [ -e "$path" ]; then + ls -ld "$path" + else + printf 'absent %s\n' "$path" + fi + done + if command -v gsettings >/dev/null 2>&1; then + gsettings list-recursively org.gnome.system.proxy 2>&1 || true + else + printf 'gsettings absent\n' + fi + if command -v systemctl >/dev/null 2>&1; then + systemctl show docker.service -p ActiveState -p SubState -p NRestarts -p ExecMainStartTimestampMonotonic 2>&1 || true + else + printf 'systemctl absent\n' + fi +} + +snapshot >"$work/before" +( + cd "$repo" + go test ./... +) +snapshot >"$work/after" +cmp "$work/before" "$work/after" +printf 'full Go suite passed without changing host Shell, APT, Docker, GNOME, or system-service state\n' diff --git a/scripts/test-release.sh b/scripts/test-release.sh new file mode 100755 index 0000000..c68a67a --- /dev/null +++ b/scripts/test-release.sh @@ -0,0 +1,33 @@ +#!/bin/sh +set -eu + +repo=$(CDPATH= cd -- "$(dirname "$0")/.." && pwd) +work=$(mktemp -d) +trap 'rm -rf "$work"' EXIT HUP INT TERM + +VERSION=v1.0.0 "$repo/scripts/build-release.sh" "$work/first" +VERSION=v1.0.0 "$repo/scripts/build-release.sh" "$work/second" + +for arch in amd64 arm64; do + binary="proxyctl-linux-$arch" + test -x "$work/first/$binary" + cmp "$work/first/$binary" "$work/second/$binary" +done +cmp "$work/first/SHA256SUMS" "$work/second/SHA256SUMS" +( + cd "$work/first" + sha256sum -c SHA256SUMS +) + +amd64_format=$(file -b "$work/first/proxyctl-linux-amd64") +arm64_format=$(file -b "$work/first/proxyctl-linux-arm64") +printf '%s\n' "$amd64_format" | grep -Eq 'ELF 64-bit.*(x86-64|x86_64)' +printf '%s\n' "$arm64_format" | grep -Eq 'ELF 64-bit.*(ARM aarch64|aarch64)' + +# Only execute the binary matching the build host; format checks cover both. +case $(go env GOARCH) in + amd64) test "$($work/first/proxyctl-linux-amd64 version --short)" = v1.0.0 ;; + arm64) test "$($work/first/proxyctl-linux-arm64 version --short)" = v1.0.0 ;; +esac + +printf 'amd64: %s\narm64: %s\nrelease automation is reproducible and checksums verify\n' "$amd64_format" "$arm64_format" diff --git a/scripts/verify-clean-machine.sh b/scripts/verify-clean-machine.sh new file mode 100755 index 0000000..d5cd30d --- /dev/null +++ b/scripts/verify-clean-machine.sh @@ -0,0 +1,74 @@ +#!/bin/sh +set -eu + +repo=$(CDPATH= cd -- "$(dirname "$0")/.." && pwd) +work=$(mktemp -d) +trap 'rm -rf "$work"' EXIT HUP INT TERM +home="$work/home" +config="$work/config" +state="$work/state" +bin="$work/bin" +mkdir -p "$home" "$config" "$state" "$bin" + +snapshot_host() { + for path in \ + "$HOME/.bashrc" "$HOME/.zshrc" "$HOME/.docker/config.json" \ + /etc/apt/apt.conf.d/99proxyctl /etc/docker/daemon.json \ + /var/lib/proxyctl/docker-daemon.json + do + if [ -r "$path" ]; then + sha256sum "$path" + elif [ -e "$path" ]; then + ls -ld "$path" + else + printf 'absent %s\n' "$path" + fi + done + if command -v gsettings >/dev/null 2>&1; then + gsettings get org.gnome.system.proxy mode 2>&1 || true + else + printf 'gsettings absent\n' + fi +} + +snapshot_host >"$work/host-before" + +( + cd "$repo" + GOFLAGS='-mod=readonly' GOBIN="$bin" go install ./cmd/proxyctl +) +test "$("$bin/proxyctl" version --short)" != "" +mv "$bin/proxyctl" "$bin/proxyctl-production" +( + cd "$repo" + GOFLAGS='-mod=readonly -tags=proxyctl_process_test' GOBIN="$bin" go install ./cmd/proxyctl + go test ./internal/app -run 'TestUserCanCreateAndActivatePersistentSingleProxyProfile|TestApplicationCoordinatesMultipleRealAndFakeHermeticAdaptersThroughPartialFailure' -count=1 +) + +env_run() { + env -i \ + PATH="$PATH" HOME="$home" XDG_CONFIG_HOME="$config" XDG_STATE_HOME="$state" \ + SHELL=/bin/bash PROXYCTL_PROCESS_TEST_HOME="$home" \ + "$bin/proxyctl" "$@" +} + +test "$(env_run version --short)" != "" +doctor_output=$(env_run doctor 2>&1 || true) +printf '%s\n' "$doctor_output" | grep -q 'Profile validity' +printf 'no\n' | env_run reset | grep -q 'Reset cancelled' +printf 'yes\n' | env_run reset | grep -q 'shell: skipped (Disabled)' +env_run shell install | grep -q 'installed' +grep -q 'proxyctl shell integration' "$home/.bashrc" +env_run shell uninstall | grep -q 'uninstalled' +if grep -q 'proxyctl shell integration' "$home/.bashrc"; then + echo 'Shell integration remained after uninstall' >&2 + exit 1 +fi + +rm "$bin/proxyctl" "$bin/proxyctl-production" +test ! -e "$bin/proxyctl" +test ! -e "$bin/proxyctl-production" +snapshot_host >"$work/host-after" +cmp "$work/host-before" "$work/host-after" + +printf 'clean-machine verification passed: go install, Profile workflow, hermetic Target workflow, doctor, reset cancellation/confirmation, Shell install/uninstall, binary removal, and unchanged host state\n'