From f4a5c9832893f4b4ecc831b0d24059b4f97befce Mon Sep 17 00:00:00 2001 From: Mathieu Virbel Date: Wed, 4 Mar 2026 08:37:49 -0600 Subject: [PATCH] feat: add `greywall check` and `greywall setup` commands Add diagnostic and setup commands so users can verify their environment and install greyproxy without leaving greywall: - `greywall check`: shows version, platform deps, security features, and greyproxy installation/running status (absorbs old --version output) - `greywall setup`: downloads greyproxy from GitHub releases and shells out to `greyproxy install`, or auto-starts if already installed - `--version` simplified to single-line output for scripting New `internal/proxy/` package handles greyproxy detection (LookPath + /api/health endpoint), GitHub release fetching, tar.gz extraction, and service lifecycle management. --- cmd/greywall/main.go | 92 +++++++++++++- internal/proxy/detect.go | 126 +++++++++++++++++++ internal/proxy/install.go | 258 ++++++++++++++++++++++++++++++++++++++ internal/proxy/start.go | 30 +++++ 4 files changed, 501 insertions(+), 5 deletions(-) create mode 100644 internal/proxy/detect.go create mode 100644 internal/proxy/install.go create mode 100644 internal/proxy/start.go diff --git a/cmd/greywall/main.go b/cmd/greywall/main.go index ee8ce65..34cda2d 100644 --- a/cmd/greywall/main.go +++ b/cmd/greywall/main.go @@ -15,6 +15,7 @@ import ( "gitea.app.monadical.io/monadical/greywall/internal/config" "gitea.app.monadical.io/monadical/greywall/internal/platform" + "gitea.app.monadical.io/monadical/greywall/internal/proxy" "gitea.app.monadical.io/monadical/greywall/internal/sandbox" "github.com/spf13/cobra" ) @@ -111,6 +112,8 @@ Configuration file format: rootCmd.AddCommand(newCompletionCmd(rootCmd)) rootCmd.AddCommand(newTemplatesCmd()) + rootCmd.AddCommand(newCheckCmd()) + rootCmd.AddCommand(newSetupCmd()) if err := rootCmd.Execute(); err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) @@ -121,11 +124,7 @@ Configuration file format: func runCommand(cmd *cobra.Command, args []string) error { if showVersion { - fmt.Printf("greywall - lightweight, container-free sandbox for running untrusted commands\n") - fmt.Printf(" Version: %s\n", version) - fmt.Printf(" Built: %s\n", buildTime) - fmt.Printf(" Commit: %s\n", gitCommit) - sandbox.PrintDependencyStatus() + fmt.Printf("greywall %s\n", version) return nil } @@ -412,6 +411,89 @@ func extractCommandName(args []string, cmdStr string) string { return filepath.Base(name) } +// newCheckCmd creates the check subcommand for diagnostics. +func newCheckCmd() *cobra.Command { + return &cobra.Command{ + Use: "check", + Short: "Check greywall status, dependencies, and greyproxy connectivity", + Long: `Run diagnostics to check greywall readiness. + +Shows version information, platform dependencies, security features, +and greyproxy installation/running status.`, + Args: cobra.NoArgs, + RunE: runCheck, + } +} + +func runCheck(_ *cobra.Command, _ []string) error { + fmt.Printf("greywall - lightweight, container-free sandbox for running untrusted commands\n") + fmt.Printf(" Version: %s\n", version) + fmt.Printf(" Built: %s\n", buildTime) + fmt.Printf(" Commit: %s\n", gitCommit) + + sandbox.PrintDependencyStatus() + + fmt.Printf("\n Greyproxy:\n") + status := proxy.Detect() + if status.Installed { + if status.Version != "" { + fmt.Printf(" ✓ installed (v%s) at %s\n", status.Version, status.Path) + } else { + fmt.Printf(" ✓ installed at %s\n", status.Path) + } + if status.Running { + fmt.Printf(" ✓ running (SOCKS5 :43052, DNS :43053, Dashboard :43080)\n") + } else { + fmt.Printf(" ✗ not running\n") + fmt.Printf(" Start with: greywall setup\n") + } + } else { + fmt.Printf(" ✗ not installed\n") + fmt.Printf(" Install with: greywall setup\n") + } + + return nil +} + +// newSetupCmd creates the setup subcommand for installing greyproxy. +func newSetupCmd() *cobra.Command { + return &cobra.Command{ + Use: "setup", + Short: "Install and start greyproxy (network proxy for sandboxed commands)", + Long: `Downloads and installs greyproxy from GitHub releases. + +greyproxy provides SOCKS5 proxying and DNS resolution for sandboxed commands. +The installer will: + 1. Download the latest greyproxy release for your platform + 2. Install the binary to ~/.local/bin/greyproxy + 3. Register and start a systemd user service`, + Args: cobra.NoArgs, + RunE: runSetup, + } +} + +func runSetup(_ *cobra.Command, _ []string) error { + status := proxy.Detect() + + if status.Installed && status.Running { + fmt.Printf("greyproxy is already installed (v%s) and running.\n", status.Version) + fmt.Printf("Run 'greywall check' for full status.\n") + return nil + } + + if status.Installed && !status.Running { + if err := proxy.Start(os.Stderr); err != nil { + return err + } + fmt.Printf("greyproxy started.\n") + return nil + } + + return proxy.Install(proxy.InstallOptions{ + Output: os.Stderr, + }) +} + // newCompletionCmd creates the completion subcommand for shell completions. func newCompletionCmd(rootCmd *cobra.Command) *cobra.Command { cmd := &cobra.Command{ diff --git a/internal/proxy/detect.go b/internal/proxy/detect.go new file mode 100644 index 0000000..7efb3c6 --- /dev/null +++ b/internal/proxy/detect.go @@ -0,0 +1,126 @@ +// Package proxy provides greyproxy detection, installation, and lifecycle management. +package proxy + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "os/exec" + "regexp" + "strings" + "time" +) + +const ( + healthURL = "http://localhost:43080/api/health" + healthTimeout = 2 * time.Second + cmdTimeout = 5 * time.Second +) + +// GreyproxyStatus holds the detected state of greyproxy. +type GreyproxyStatus struct { + Installed bool // found via exec.LookPath + Path string // full path from LookPath + Version string // parsed version (e.g. "0.1.1") + Running bool // health endpoint responded with valid greyproxy response + RunningErr error // error from the running check (for diagnostics) +} + +// healthResponse is the expected JSON from GET /api/health. +type healthResponse struct { + Service string `json:"service"` + Version string `json:"version"` + Status string `json:"status"` + Ports map[string]int `json:"ports"` +} + +var versionRegex = regexp.MustCompile(`^greyproxy\s+(\S+)`) + +// Detect checks greyproxy installation status, version, and whether it's running. +// This function never returns an error; all detection failures are captured +// in the GreyproxyStatus fields so the caller can present them diagnostically. +func Detect() *GreyproxyStatus { + s := &GreyproxyStatus{} + + // 1. Check if installed + s.Path, s.Installed = checkInstalled() + + // 2. Check if running (via health endpoint) + running, ver, err := checkRunning() + s.Running = running + s.RunningErr = err + if running && ver != "" { + s.Version = ver + } + + // 3. Version fallback: if installed but version not yet known, parse from CLI + if s.Installed && s.Version == "" { + s.Version, _ = checkVersion(s.Path) + } + + return s +} + +// checkInstalled uses exec.LookPath to find greyproxy on PATH. +func checkInstalled() (path string, found bool) { + p, err := exec.LookPath("greyproxy") + if err != nil { + return "", false + } + return p, true +} + +// checkVersion runs "greyproxy -V" and parses the output. +// Expected format: "greyproxy 0.1.1 (go1.x linux/amd64)" +func checkVersion(binaryPath string) (string, error) { + ctx, cancel := context.WithTimeout(context.Background(), cmdTimeout) + defer cancel() + + out, err := exec.CommandContext(ctx, binaryPath, "-V").Output() //nolint:gosec // binaryPath comes from exec.LookPath + if err != nil { + return "", fmt.Errorf("failed to run greyproxy -V: %w", err) + } + + matches := versionRegex.FindStringSubmatch(strings.TrimSpace(string(out))) + if len(matches) < 2 { + return "", fmt.Errorf("unexpected version output: %s", strings.TrimSpace(string(out))) + } + return matches[1], nil +} + +// checkRunning hits GET http://localhost:43080/api/health and verifies +// the response is from greyproxy (not some other service on that port). +// Returns running status, version string from health response, and any error. +func checkRunning() (bool, string, error) { + client := &http.Client{Timeout: healthTimeout} + + ctx, cancel := context.WithTimeout(context.Background(), healthTimeout) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, healthURL, nil) + if err != nil { + return false, "", fmt.Errorf("failed to create request: %w", err) + } + + resp, err := client.Do(req) //nolint:gosec // healthURL is a hardcoded localhost constant + if err != nil { + return false, "", fmt.Errorf("health check failed: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return false, "", fmt.Errorf("health check returned status %d", resp.StatusCode) + } + + var health healthResponse + if err := json.NewDecoder(resp.Body).Decode(&health); err != nil { + return false, "", fmt.Errorf("failed to parse health response: %w", err) + } + + if health.Service != "greyproxy" { + return false, "", fmt.Errorf("unexpected service: %q (expected greyproxy)", health.Service) + } + + return true, health.Version, nil +} diff --git a/internal/proxy/install.go b/internal/proxy/install.go new file mode 100644 index 0000000..161b56a --- /dev/null +++ b/internal/proxy/install.go @@ -0,0 +1,258 @@ +package proxy + +import ( + "archive/tar" + "compress/gzip" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "time" +) + +const ( + githubOwner = "greyhavenhq" + githubRepo = "greyproxy" + apiTimeout = 15 * time.Second +) + +// release represents a GitHub release. +type release struct { + TagName string `json:"tag_name"` + Assets []asset `json:"assets"` +} + +// asset represents a release asset. +type asset struct { + Name string `json:"name"` + BrowserDownloadURL string `json:"browser_download_url"` +} + +// InstallOptions controls the greyproxy installation behavior. +type InstallOptions struct { + Output io.Writer // progress output (typically os.Stderr) +} + +// Install downloads the latest greyproxy release and runs "greyproxy install". +func Install(opts InstallOptions) error { + if opts.Output == nil { + opts.Output = os.Stderr + } + + // 1. Fetch latest release + _, _ = fmt.Fprintf(opts.Output, "Fetching latest greyproxy release...\n") + rel, err := fetchLatestRelease() + if err != nil { + return fmt.Errorf("failed to fetch latest release: %w", err) + } + ver := strings.TrimPrefix(rel.TagName, "v") + _, _ = fmt.Fprintf(opts.Output, "Latest version: %s\n", ver) + + // 2. Find the correct asset for this platform + assetURL, assetName, err := resolveAssetURL(rel) + if err != nil { + return err + } + _, _ = fmt.Fprintf(opts.Output, "Downloading %s...\n", assetName) + + // 3. Download to temp file + archivePath, err := downloadAsset(assetURL) + if err != nil { + return fmt.Errorf("download failed: %w", err) + } + defer func() { _ = os.Remove(archivePath) }() + + // 4. Extract + _, _ = fmt.Fprintf(opts.Output, "Extracting...\n") + extractDir, err := extractTarGz(archivePath) + if err != nil { + return fmt.Errorf("extraction failed: %w", err) + } + defer func() { _ = os.RemoveAll(extractDir) }() + + // 5. Find the greyproxy binary in extracted content + binaryPath := filepath.Join(extractDir, "greyproxy") + if _, err := os.Stat(binaryPath); err != nil { + return fmt.Errorf("greyproxy binary not found in archive") + } + + // 6. Shell out to "greyproxy install" + _, _ = fmt.Fprintf(opts.Output, "\n") + if err := runGreyproxyInstall(binaryPath); err != nil { + return fmt.Errorf("greyproxy install failed: %w", err) + } + + // 7. Verify + _, _ = fmt.Fprintf(opts.Output, "\nVerifying installation...\n") + status := Detect() + if status.Installed { + _, _ = fmt.Fprintf(opts.Output, "greyproxy %s installed at %s\n", status.Version, status.Path) + if status.Running { + _, _ = fmt.Fprintf(opts.Output, "greyproxy is running.\n") + } + } else { + _, _ = fmt.Fprintf(opts.Output, "Warning: greyproxy not found on PATH after install.\n") + _, _ = fmt.Fprintf(opts.Output, "Ensure ~/.local/bin is in your PATH.\n") + } + + return nil +} + +// fetchLatestRelease queries the GitHub API for the latest greyproxy release. +func fetchLatestRelease() (*release, error) { + apiURL := fmt.Sprintf("https://api.github.com/repos/%s/%s/releases/latest", githubOwner, githubRepo) + + client := &http.Client{Timeout: apiTimeout} + + ctx, cancel := context.WithTimeout(context.Background(), apiTimeout) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("User-Agent", "greywall-setup") + + resp, err := client.Do(req) //nolint:gosec // apiURL is built from hardcoded constants + if err != nil { + return nil, fmt.Errorf("GitHub API request failed: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("GitHub API returned status %d", resp.StatusCode) + } + + var rel release + if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil { + return nil, fmt.Errorf("failed to parse release response: %w", err) + } + return &rel, nil +} + +// resolveAssetURL finds the correct asset download URL for the current OS/arch. +func resolveAssetURL(rel *release) (downloadURL, name string, err error) { + ver := strings.TrimPrefix(rel.TagName, "v") + osName := runtime.GOOS + archName := runtime.GOARCH + + expected := fmt.Sprintf("greyproxy_%s_%s_%s.tar.gz", ver, osName, archName) + + for _, a := range rel.Assets { + if a.Name == expected { + return a.BrowserDownloadURL, a.Name, nil + } + } + return "", "", fmt.Errorf("no release asset found for %s/%s (expected: %s)", osName, archName, expected) +} + +// downloadAsset downloads a URL to a temp file, returning its path. +func downloadAsset(downloadURL string) (string, error) { + client := &http.Client{Timeout: 5 * time.Minute} + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, nil) + if err != nil { + return "", err + } + + resp, err := client.Do(req) //nolint:gosec // downloadURL comes from GitHub API response + if err != nil { + return "", err + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("download returned status %d", resp.StatusCode) + } + + tmpFile, err := os.CreateTemp("", "greyproxy-*.tar.gz") + if err != nil { + return "", err + } + + if _, err := io.Copy(tmpFile, resp.Body); err != nil { + _ = tmpFile.Close() + _ = os.Remove(tmpFile.Name()) //nolint:gosec // tmpFile.Name() is from os.CreateTemp, not user input + return "", err + } + _ = tmpFile.Close() + + return tmpFile.Name(), nil +} + +// extractTarGz extracts a .tar.gz archive to a temp directory, returning the dir path. +func extractTarGz(archivePath string) (string, error) { + f, err := os.Open(archivePath) //nolint:gosec // archivePath is a temp file we created + if err != nil { + return "", err + } + defer func() { _ = f.Close() }() + + gz, err := gzip.NewReader(f) + if err != nil { + return "", fmt.Errorf("failed to create gzip reader: %w", err) + } + defer func() { _ = gz.Close() }() + + tmpDir, err := os.MkdirTemp("", "greyproxy-extract-*") + if err != nil { + return "", err + } + + tr := tar.NewReader(gz) + for { + header, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + _ = os.RemoveAll(tmpDir) + return "", fmt.Errorf("tar read error: %w", err) + } + + // Sanitize: only extract regular files with safe names + name := filepath.Base(header.Name) + if name == "." || name == ".." || strings.Contains(header.Name, "..") { + continue + } + + target := filepath.Join(tmpDir, name) //nolint:gosec // name is sanitized via filepath.Base and path traversal check above + + switch header.Typeflag { + case tar.TypeReg: + out, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, os.FileMode(header.Mode)) //nolint:gosec // mode from tar header of trusted archive + if err != nil { + _ = os.RemoveAll(tmpDir) + return "", err + } + if _, err := io.Copy(out, io.LimitReader(tr, 256<<20)); err != nil { // 256 MB limit per file + _ = out.Close() + _ = os.RemoveAll(tmpDir) + return "", err + } + _ = out.Close() + } + } + + return tmpDir, nil +} + +// runGreyproxyInstall shells out to the extracted greyproxy binary with "install" arg. +// Stdin/stdout/stderr are passed through so the interactive [y/N] prompt works. +func runGreyproxyInstall(binaryPath string) error { + cmd := exec.Command(binaryPath, "install") //nolint:gosec // binaryPath is from our extracted archive + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} diff --git a/internal/proxy/start.go b/internal/proxy/start.go new file mode 100644 index 0000000..7a92a9b --- /dev/null +++ b/internal/proxy/start.go @@ -0,0 +1,30 @@ +package proxy + +import ( + "fmt" + "io" + "os" + "os/exec" +) + +// Start runs "greyproxy service start" to start the greyproxy service. +func Start(output io.Writer) error { + if output == nil { + output = os.Stderr + } + + path, found := checkInstalled() + if !found { + return fmt.Errorf("greyproxy not found on PATH") + } + + _, _ = fmt.Fprintf(output, "Starting greyproxy service...\n") + cmd := exec.Command(path, "service", "start") //nolint:gosec // path comes from exec.LookPath + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("failed to start greyproxy service: %w", err) + } + + return nil +}