feat: publish verifiable proxyctl v1
This commit is contained in:
@@ -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
|
||||
@@ -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.
|
||||
+22
-4
@@ -27,6 +27,9 @@ import (
|
||||
"proxyctl/internal/tui"
|
||||
)
|
||||
|
||||
// version is overridden for releases with -ldflags "-X main.version=<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]")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"}}
|
||||
|
||||
@@ -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"),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Executable
+39
@@ -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"
|
||||
Executable
+23
@@ -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'
|
||||
Executable
+43
@@ -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'
|
||||
Executable
+33
@@ -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"
|
||||
Executable
+74
@@ -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'
|
||||
Reference in New Issue
Block a user