diff --git a/CHANGELOG.md b/CHANGELOG.md index e072dc3..3d262c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Light and dark mode, chosen under Appearance in Settings; the default follows the system setting. - Clicking a service row copies its agents call URL to the clipboard. +- A warning above the service list when the config is in a git repository and + not ignored, so API keys are not committed by accident. ### Changed diff --git a/README.md b/README.md index a13a17a..9f52f55 100644 --- a/README.md +++ b/README.md @@ -200,7 +200,9 @@ Only schemes that boil down to a fixed header value are offered. Traefik cannot sign requests, so AWS Signature, Digest, OAuth 1.0 and similar are out of scope. Secrets are stored in plain text in the Traefik YAML, as Traefik requires. Keep -the config directory private. +the config directory private and out of git. When the config sits in a git +repository without being ignored, Agent Proxy shows a warning above the list, +since a commit there would publish the keys. ### The service list diff --git a/main.go b/main.go index 031e256..ebd0aa5 100644 --- a/main.go +++ b/main.go @@ -13,6 +13,7 @@ import ( "net/http" "net/url" "os" + "os/exec" "path/filepath" "regexp" "sort" @@ -373,6 +374,43 @@ func deleteService(target string, isFile bool, name string) error { return fs.ErrNotExist } +// ---------- git ---------- + +// gitExposure returns the root of the git work tree holding path when git does not ignore path, +// so a commit there would publish the API keys Agent Proxy writes. "" means ignored, not in a +// work tree, or git unavailable. For a directory, pass a would-be service file inside it. +func gitExposure(path string) string { + dir := filepath.Dir(path) + // Look for .git first: on a Mac without the developer tools, running /usr/bin/git opens an install prompt. + if !inWorkTree(dir) { + return "" + } + root, err := exec.Command("git", "-C", dir, "rev-parse", "--show-toplevel").Output() + if err != nil { + return "" + } + // Exit 1 means not ignored; tracked files never count as ignored. The base name keeps git from + // rejecting an absolute path that reaches the work tree through a symlink, such as /tmp. + var exit *exec.ExitError + if err := exec.Command("git", "-C", dir, "check-ignore", "-q", filepath.Base(path)).Run(); errors.As(err, &exit) && exit.ExitCode() == 1 { + return strings.TrimSpace(string(root)) + } + return "" +} + +func inWorkTree(dir string) bool { + for { + if _, err := os.Stat(filepath.Join(dir, ".git")); err == nil { + return true + } + parent := filepath.Dir(dir) + if parent == dir { + return false + } + dir = parent + } +} + // ---------- traefik ---------- func httpURL(s string) error { @@ -1095,6 +1133,11 @@ func main() { // The separator keeps the navy header apart from the dark palette's near-navy background; on light it is barely visible. header := container.New(layout.NewCustomPaddedVBoxLayout(0), container.NewStack(canvas.NewRectangle(navy), container.NewPadded(container.NewPadded(container.NewBorder(nil, nil, container.NewHBox(container.NewCenter(title), container.NewCenter(where)), container.NewHBox(connDot, container.NewCenter(conn)))))), widget.NewSeparator()) + // Shown while the config is in a git work tree without being ignored; Settings is where to fix it. + exposedText := widget.NewLabel("") + exposedText.Wrapping = fyne.TextWrapWord + exposed := container.NewBorder(nil, nil, container.NewCenter(widget.NewIcon(theme.NewWarningThemedResource(theme.WarningIcon()))), nil, exposedText) + exposed.Hide() var svcs []Service var target string @@ -1117,6 +1160,16 @@ func main() { conn.Refresh() where.Text = target where.Refresh() + probe := target + if !isFile { + probe = filepath.Join(target, "service.yml") + } + if root := gitExposure(probe); root != "" { + exposedText.SetText("Git does not ignore this config in " + root + ", so API keys saved here can end up in a commit. Point Settings at a gitignored directory.") + exposed.Show() + } else { + exposed.Hide() + } base := strings.TrimRight(s.Base, "/") rows = rows[:0] for _, v := range svcs { @@ -1194,7 +1247,7 @@ func main() { }) }), ) - body := container.NewBorder(container.NewVBox(buttons, head, widget.NewSeparator()), nil, nil, nil, container.NewStack(list, empty)) + body := container.NewBorder(container.NewVBox(exposed, buttons, head, widget.NewSeparator()), nil, nil, nil, container.NewStack(list, empty)) w.SetContent(container.NewStack(container.NewBorder(header, nil, nil, nil, container.NewPadded(body)), notice.layer)) if _, _, err := resolve(s.Config); err != nil { editSettings(w, &s, err.Error(), func(ok bool) { diff --git a/main_test.go b/main_test.go index 5a71624..5c283e5 100644 --- a/main_test.go +++ b/main_test.go @@ -2,6 +2,7 @@ package main import ( "os" + "os/exec" "path/filepath" "reflect" "strings" @@ -107,3 +108,36 @@ func TestDisabledRoundTrip(t *testing.T) { t.Fatalf("re-enable: %+v", got) } } + +func TestGitExposure(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not installed") + } + repo := t.TempDir() // a symlinked path on macOS, which gitExposure must handle + git := func(args ...string) { + if out, err := exec.Command("git", append([]string{"-C", repo}, args...)...).CombinedOutput(); err != nil { + t.Fatalf("git %v: %v\n%s", args, err, out) + } + } + git("init", "-q") + os.Mkdir(filepath.Join(repo, "local"), 0o700) + os.WriteFile(filepath.Join(repo, ".gitignore"), []byte("local/\nforced.yaml\n"), 0o600) + for _, f := range []string{"tracked.yaml", "untracked.yaml", "forced.yaml"} { + os.WriteFile(filepath.Join(repo, f), []byte("http: {}\n"), 0o600) + } + git("add", "tracked.yaml") + git("add", "-f", "forced.yaml") // tracked despite matching .gitignore + + outside := filepath.Join(t.TempDir(), "x.yaml") + for path, exposed := range map[string]bool{ + filepath.Join(repo, "tracked.yaml"): true, + filepath.Join(repo, "untracked.yaml"): true, + filepath.Join(repo, "forced.yaml"): true, + filepath.Join(repo, "local", "svc.yml"): false, + outside: false, + } { + if got := gitExposure(path) != ""; got != exposed { + t.Errorf("gitExposure(%s) exposed=%v, want %v", path, got, exposed) + } + } +}