Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ curl -fsSL https://raw.githubusercontent.com/GreyhavenHQ/greywall/main/install.s

```bash
go install github.com/GreyhavenHQ/greywall/cmd/greywall@latest
# greyproxy is not included — install it separately:
greywall setup
```

**[mise](https://mise.jdx.dev/):**
Expand All @@ -77,6 +79,8 @@ mise use -g github:GreyhavenHQ/greyproxy
git clone https://github.com/GreyhavenHQ/greywall
cd greywall
make setup && make build
# greyproxy is not included — install it separately:
greywall setup
```

</details>
Expand Down
169 changes: 169 additions & 0 deletions cmd/greywall/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ package main
import (
"encoding/json"
"fmt"
"io"
"net/url"
"os"
"os/exec"
Expand Down Expand Up @@ -141,6 +142,7 @@ Configuration file format:
rootCmd.AddCommand(newProfilesCmd())
rootCmd.AddCommand(newCheckCmd())
rootCmd.AddCommand(newSetupCmd())
rootCmd.AddCommand(newUpdateCmd())

if err := rootCmd.Execute(); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
Expand Down Expand Up @@ -878,6 +880,173 @@ func runSetup(_ *cobra.Command, _ []string) error {
})
}

// newUpdateCmd creates the update subcommand for updating greywall and greyproxy.
func newUpdateCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "update",
Short: "Update greywall and greyproxy to the latest release",
Long: `Updates greywall and greyproxy by downloading the latest release binaries.

The update method depends on how greywall was originally installed:
- Installed via install.sh → downloads and replaces binaries automatically
- Installed via Homebrew → prints the brew upgrade command to run
- Installed from source → prints manual instructions

Examples:
greywall update # update to latest stable
greywall update --beta # update to latest beta`,
Args: cobra.NoArgs,
RunE: runUpdate,
}
cmd.Flags().Bool("beta", false, "Update to the latest beta (pre-release) version")
return cmd
}

// installMethod represents how greywall was originally installed.
type installMethod int

const (
installMethodScript installMethod = iota // install.sh → ~/.local/bin
installMethodBrew // Homebrew
installMethodSource // built from source / unknown
)

// detectInstallMethod returns how the currently running greywall was installed.
func detectInstallMethod(selfPath string) installMethod {
if proxy.IsBrewManaged(selfPath) {
return installMethodBrew
}
home, err := os.UserHomeDir()
if err == nil {
localBin := filepath.Join(home, ".local", "bin")
if filepath.Dir(selfPath) == localBin {
return installMethodScript
}
}
return installMethodSource
}

func runUpdate(cmd *cobra.Command, _ []string) error {
beta, _ := cmd.Flags().GetBool("beta")

// Fetch latest tags for both tools
greyproxyTag, err := proxy.CheckLatestTag(beta)
if err != nil {
if beta {
return fmt.Errorf("no beta release available for greyproxy yet — try 'greywall update' for the latest stable")
}
return fmt.Errorf("failed to fetch latest greyproxy tag: %w", err)
}
greywallTag, err := proxy.CheckLatestTagFor("GreyhavenHQ", "greywall", beta)
if err != nil {
if beta {
return fmt.Errorf("no beta release available for greywall yet — try 'greywall update' for the latest stable")
}
return fmt.Errorf("failed to fetch latest greywall tag: %w", err)
}

channel := "stable"
if beta {
channel = "beta"
}
fmt.Printf("Latest %s: greywall %s, greyproxy %s\n\n", channel, greywallTag, greyproxyTag)

// Detect how greywall was installed (resolve symlinks first)
selfPath, err := os.Executable()
if err != nil {
selfPath = ""
} else if resolved, err := filepath.EvalSymlinks(selfPath); err == nil {
selfPath = resolved
}

switch detectInstallMethod(selfPath) {
case installMethodBrew:
fmt.Printf("greywall is managed by Homebrew. To update both tools, run:\n")
fmt.Printf(" brew upgrade greywall greyproxy\n")
return nil

case installMethodSource:
fmt.Printf("greywall was installed from source. To update manually:\n\n")
fmt.Printf(" greywall:\n")
fmt.Printf(" git clone --branch %s https://github.com/GreyhavenHQ/greywall.git\n", greywallTag)
fmt.Printf(" cd greywall && make build && cp greywall ~/.local/bin/\n\n")
fmt.Printf(" greyproxy:\n")
fmt.Printf(" git clone --branch %s https://github.com/GreyhavenHQ/greyproxy.git\n", greyproxyTag)
fmt.Printf(" cd greyproxy && go build -o greyproxy ./cmd/greyproxy && greyproxy install --force\n")
return nil // exit 0 — not broken, just can't automate

default: // installMethodScript
// Update greyproxy via binary download
fmt.Println("==> Updating greyproxy...")
greyproxyStatus := proxy.Detect()
if !proxy.IsOlderVersion(greyproxyStatus.Version, greyproxyTag) {
fmt.Printf("greyproxy is already up to date (%s)\n", greyproxyTag)
} else if err := proxy.Install(proxy.InstallOptions{
Output: os.Stderr,
Tag: greyproxyTag,
}); err != nil {
return fmt.Errorf("failed to update greyproxy: %w", err)
}

// Update greywall via binary download
fmt.Println("\n==> Updating greywall...")
if !proxy.IsOlderVersion(version, greywallTag) {
fmt.Printf("greywall is already up to date (%s)\n", greywallTag)
} else if err := updateSelf(greywallTag, selfPath, os.Stderr); err != nil {
return fmt.Errorf("failed to update greywall: %w", err)
}
fmt.Printf("\ngreywall and greyproxy are up to date on %s channel.\n", channel)
return nil
}
}

// updateSelf downloads the greywall release binary and replaces the running binary at execPath.
func updateSelf(tag, execPath string, output io.Writer) error {
_, _ = fmt.Fprintf(output, "Downloading greywall %s...\n", tag)

binPath, cleanup, err := proxy.DownloadGreywallBinary(tag)
if err != nil {
return err
}
defer cleanup()

_, _ = fmt.Fprintf(output, "Replacing %s...\n", execPath)
if err := os.Rename(binPath, execPath); err != nil {
// Rename across filesystems fails; copy instead
if err2 := copyFileTo(binPath, execPath); err2 != nil {
return fmt.Errorf("failed to replace binary: %w (copy also failed: %v)", err, err2)
}
}

_, _ = fmt.Fprintf(output, "greywall updated to %s\n", tag)
return nil
}

// copyFileTo copies src to dst atomically (write to temp, then rename).
func copyFileTo(src, dst string) error {
in, err := os.Open(src) //nolint:gosec // src is a path we control
if err != nil {
return err
}
defer func() { _ = in.Close() }()

tmpDst := dst + ".new"
out, err := os.OpenFile(tmpDst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o755) //nolint:gosec
if err != nil {
return err
}
defer func() { _ = os.Remove(tmpDst) }()

if _, err := io.Copy(out, in); err != nil {
_ = out.Close()
return err
}
if err := out.Close(); err != nil {
return err
}
return os.Rename(tmpDst, dst)
}

// newCompletionCmd creates the completion subcommand for shell completions.
func newCompletionCmd(rootCmd *cobra.Command) *cobra.Command {
cmd := &cobra.Command{
Expand Down
70 changes: 70 additions & 0 deletions cmd/greywall/main_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package main

import (
"os"
"path/filepath"
"testing"
)

func TestCopyFileTo(t *testing.T) {
t.Run("copies content and sets executable bit", func(t *testing.T) {
dir := t.TempDir()
src := filepath.Join(dir, "src")
dst := filepath.Join(dir, "dst")

content := []byte("hello binary")
if err := os.WriteFile(src, content, 0o600); err != nil {
t.Fatal(err)
}

if err := copyFileTo(src, dst); err != nil {
t.Fatalf("copyFileTo: %v", err)
}

got, err := os.ReadFile(dst) //nolint:gosec // dst is a temp file path from t.TempDir()
if err != nil {
t.Fatalf("reading dst: %v", err)
}
if string(got) != string(content) {
t.Errorf("content mismatch: got %q, want %q", got, content)
}

info, err := os.Stat(dst)
if err != nil {
t.Fatal(err)
}
if info.Mode()&0o111 == 0 {
t.Errorf("dst is not executable: mode %v", info.Mode())
}
})

t.Run("overwrites existing dst", func(t *testing.T) {
dir := t.TempDir()
src := filepath.Join(dir, "src")
dst := filepath.Join(dir, "dst")

if err := os.WriteFile(dst, []byte("old"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(src, []byte("new"), 0o600); err != nil {
t.Fatal(err)
}

if err := copyFileTo(src, dst); err != nil {
t.Fatalf("copyFileTo: %v", err)
}

got, _ := os.ReadFile(dst) //nolint:gosec // dst is a temp file path from t.TempDir()
if string(got) != "new" {
t.Errorf("got %q, want %q", got, "new")
}
})

t.Run("returns error for missing src", func(t *testing.T) {
dir := t.TempDir()
err := copyFileTo(filepath.Join(dir, "nonexistent"), filepath.Join(dir, "dst"))
if err == nil {
t.Fatal("expected error for missing src, got nil")
}
})
}
Loading
Loading