Skip to content
Closed
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
55 changes: 54 additions & 1 deletion main.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"net/http"
"net/url"
"os"
"os/exec"
"path/filepath"
"regexp"
"sort"
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand All @@ -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 {
Expand Down Expand Up @@ -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) {
Expand Down
34 changes: 34 additions & 0 deletions main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package main

import (
"os"
"os/exec"
"path/filepath"
"reflect"
"strings"
Expand Down Expand Up @@ -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)
}
}
}