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"
|
"proxyctl/internal/tui"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// version is overridden for releases with -ldflags "-X main.version=<version>".
|
||||||
|
var version = "dev"
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
if err := run(os.Args[1:], os.Stdin, os.Stdout); err != nil {
|
if err := run(os.Args[1:], os.Stdin, os.Stdout); err != nil {
|
||||||
fmt.Fprintln(os.Stderr, "proxyctl:", err)
|
fmt.Fprintln(os.Stderr, "proxyctl:", err)
|
||||||
@@ -50,11 +53,17 @@ func runWithSystemTargets(args []string, input io.Reader, output io.Writer, syst
|
|||||||
|
|
||||||
type processEnvironment struct{}
|
type processEnvironment struct{}
|
||||||
|
|
||||||
|
var (
|
||||||
|
processCurrentUser = profile.SystemCurrentUser
|
||||||
|
processLookupUser = profile.SystemLookupUser
|
||||||
|
processProductionTargets = productionTargets
|
||||||
|
)
|
||||||
|
|
||||||
func (processEnvironment) CurrentUser() (profile.UserIdentity, error) {
|
func (processEnvironment) CurrentUser() (profile.UserIdentity, error) {
|
||||||
return profile.SystemCurrentUser()
|
return processCurrentUser()
|
||||||
}
|
}
|
||||||
func (processEnvironment) LookupUser(name string) (profile.UserIdentity, error) {
|
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) }
|
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 {
|
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)
|
profileConfiguration, err := profile.ResolveConfiguration(environment)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if len(args) == 1 && args[0] == "doctor" {
|
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)
|
shellTarget := shell.New(shellConfiguration, files, environment)
|
||||||
if systemTargets == nil {
|
if systemTargets == nil {
|
||||||
systemTargets, err = productionTargets(environment)
|
systemTargets, err = processProductionTargets(environment)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -129,7 +147,7 @@ func runWithEnvironmentAndTargets(args []string, input io.Reader, output io.Writ
|
|||||||
case len(args) == 1 && args[0] == "reset":
|
case len(args) == 1 && args[0] == "reset":
|
||||||
return runMaintenanceCommand(args, input, output, store, targets, mutationLock, logger, nil)
|
return runMaintenanceCommand(args, input, output, store, targets, mutationLock, logger, nil)
|
||||||
default:
|
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"
|
"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) {
|
func TestProductionCompositionRegistersAPTContainerGNOMEAndDaemonTargets(t *testing.T) {
|
||||||
home := t.TempDir()
|
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"}}
|
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()
|
root := t.TempDir()
|
||||||
binary := filepath.Join(root, "proxyctl")
|
binary := filepath.Join(root, "bin", "proxyctl")
|
||||||
build := exec.Command("go", "build", "-o", binary, ".")
|
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 {
|
if output, err := build.CombinedOutput(); err != nil {
|
||||||
t.Fatalf("build proxyctl: %v\n%s", err, output)
|
t.Fatalf("build proxyctl: %v\n%s", err, output)
|
||||||
}
|
}
|
||||||
environment := isolatedProcessEnvironment(root)
|
environment := isolatedProcessEnvironment(root)
|
||||||
|
|
||||||
doctor := exec.Command(binary, "doctor")
|
invoke := func(input string, args ...string) ([]byte, error) {
|
||||||
doctor.Env = environment
|
t.Helper()
|
||||||
doctorOutput, doctorErr := doctor.CombinedOutput()
|
command := exec.Command(binary, args...)
|
||||||
if doctorErr == nil || !strings.Contains(string(doctorOutput), "[FAIL]") || !strings.Contains(string(doctorOutput), "Target availability") {
|
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)
|
t.Fatalf("doctor process err=%v output=%q", doctorErr, doctorOutput)
|
||||||
}
|
}
|
||||||
|
resetOutput, err := invoke("no\n", "reset")
|
||||||
cancel := exec.Command(binary, "reset")
|
if err != nil || !strings.Contains(string(resetOutput), "Reset cancelled") {
|
||||||
cancel.Env = environment
|
t.Fatalf("reset cancellation process err=%v output=%q", err, resetOutput)
|
||||||
cancel.Stdin = strings.NewReader("no\n")
|
}
|
||||||
cancelOutput, err := cancel.CombinedOutput()
|
resetOutput, err = invoke("yes\n", "reset")
|
||||||
if err != nil || !strings.Contains(string(cancelOutput), "Reset cancelled") {
|
if err != nil || !strings.Contains(string(resetOutput), "shell: skipped (Disabled)") {
|
||||||
t.Fatalf("cancel process err=%v output=%q", err, cancelOutput)
|
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")
|
for _, path := range []string{
|
||||||
confirmed.Env = environment
|
filepath.Join(root, "etc"),
|
||||||
confirmed.Stdin = strings.NewReader("yes\n")
|
filepath.Join(root, "var"),
|
||||||
confirmedOutput, err := confirmed.CombinedOutput()
|
filepath.Join(root, "home", ".docker"),
|
||||||
if err != nil || !strings.Contains(string(confirmedOutput), "shell: skipped (Disabled)") {
|
} {
|
||||||
t.Fatalf("confirmed process err=%v output=%q", err, confirmedOutput)
|
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
||||||
}
|
t.Fatalf("process smoke created prohibited system/Docker fixture %s: %v", path, err)
|
||||||
if _, err := os.Stat(filepath.Join(root, "home", ".bashrc")); !os.IsNotExist(err) {
|
}
|
||||||
t.Fatalf("process smoke test touched a Shell startup file: %v", err)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,7 +201,8 @@ func isolatedProcessEnvironment(root string) []string {
|
|||||||
"HOME="+filepath.Join(root, "home"),
|
"HOME="+filepath.Join(root, "home"),
|
||||||
"XDG_CONFIG_HOME="+filepath.Join(root, "config"),
|
"XDG_CONFIG_HOME="+filepath.Join(root, "config"),
|
||||||
"XDG_STATE_HOME="+filepath.Join(root, "state"),
|
"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