diff --git a/docs/plan.md b/docs/plan.md index ff22221..c44e4c8 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -60,7 +60,7 @@ This document is the source of truth for scope and the work breakdown. How we wo | `vapid-file` | `nudge serve` can take its VAPID key from a file or the environment, for a host with no Keychain (decision 35); it is `NUDGE_VAPID_KEY_FILE`, with no key in the settings file | Done (#44) | | `ingress-check` | A throwaway pod behind a `tailscale` Ingress in the homelab: does the proxy send `Tailscale-User-Login` and the Ingress's name as `Host`, and does it keep a request open for hours (risk 12). Done: the proxy sends both; a request held 330 s was answered; hours untested | Done | | `container-image` | A Dockerfile in this repo: a static `linux/amd64` Go binary, non-root, `nudge serve` as the entrypoint. Done: it cross-compiles on the build host (QEMU crashed the Go toolchain on a Mac); 5.7 MB; ran with a read-only root and answered 200 with the login header and 403 without | Done | -| `server-setting` | A `server` key in the settings file, read by `nudge ask` and `nudge mcp` as `NUDGE_SERVER` is (decision 36) | Todo | +| `server-setting` | A `server` key in the settings file, read by `nudge ask` and `nudge mcp` as `NUDGE_SERVER` is (decision 36). `nudge setup` doesn't write it yet; the user adds it to the settings file by hand | Done | | `image-workflow` | `.gitea/workflows/image.yaml`: builds with BuildKit and pushes `git.tail9e9088.ts.net/codedogapp/nudge:` from the Gitea mirror | Todo | | `k8s-manifests` | `cluster/nudge/` in the homelab repo (plain YAML, Flux): Deployment, volume, Service, Ingress, and a doc there (decision 36) | Todo | | `cutover` | Tag v0.4.0, deploy, point the Macs and the iPhone at the cluster; after the user confirms it works, stop the Mac's `brew services` nudge and `tailscale serve` | Todo | diff --git a/docs/setup.md b/docs/setup.md index 2c63109..c448222 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -44,7 +44,7 @@ It does the steps of [Settings](#settings), [Tailscale](#tailscale) and [Agents] | `NUDGE_SECURITY` | | The `security` tool that keeps the VAPID key. Only the tests change it, to point at a stand-in | `/usr/bin/security` | | `NUDGE_SOCKET` | | The agent door's socket, for `nudge serve`, `nudge ask` and `nudge mcp`. It has no key, because `nudge ask` and `nudge mcp` read only the environment | `~/Library/Application Support/nudge/agent.sock` | | `NUDGE_VAPID_KEY_FILE` | | A file for the VAPID key, for a host with no Keychain, such as a container. `nudge serve` makes the key there on its first start, with mode 0600, in a folder that must exist, or reads the one that is there ([The VAPID key](#the-vapid-key)) | The login Keychain | -| `NUDGE_SERVER` | | For `nudge ask` and `nudge mcp` on another machine: the https URL of the machine's `nudge serve` on the tailnet, such as `https://machine.tailnet.ts.net:8443`. When it is set, they use it in place of the socket, and the Tailscale login of the machine must be the one that `nudge serve` lets in ([the plan](plan.md), decision 35) | The socket | +| `NUDGE_SERVER` | `server` | For `nudge ask` and `nudge mcp` on another machine: the https URL of the machine's `nudge serve` on the tailnet, such as `https://machine.tailnet.ts.net:8443`. When the variable or the key is set (the variable wins), they use it in place of the socket, and the Tailscale login of the machine must be the one that `nudge serve` lets in ([the plan](plan.md), decision 35) | The socket | | `NUDGE_SUBSCRIPTIONS` | `subscriptions` | The file that keeps the devices' push subscriptions. In the settings file, an absolute path | `~/Library/Application Support/nudge/subscriptions.json` | The settings file suits `brew services`, which starts `nudge serve` without the shell's variables ([Run at login](#run-at-login)). It holds a JSON object: diff --git a/internal/agentdoor/remote.go b/internal/agentdoor/remote.go index 5ff70f2..cf1098b 100644 --- a/internal/agentdoor/remote.go +++ b/internal/agentdoor/remote.go @@ -5,12 +5,8 @@ import ( "fmt" "net/http" "net/url" - "os" ) -// serverVar names the variable that holds the https URL of a nudge serve on another machine. -const serverVar = "NUDGE_SERVER" - // remote sends each request to the agent endpoints of a nudge serve on another machine. type remote struct { server *url.URL @@ -27,10 +23,10 @@ func (r remote) RoundTrip(req *http.Request) (*http.Response, error) { return r.base.RoundTrip(moved) } -// Connect returns the client for the agent door: one that reaches the nudge serve at NUDGE_SERVER -// through the tailnet, when it is set, else one that reaches the agent socket. -func Connect() (*http.Client, error) { - if server := os.Getenv(serverVar); server != "" { +// Connect returns the client for the agent door: one that reaches the nudge serve at server, an +// https URL, through the tailnet, when server is set, else one that reaches the agent socket. +func Connect(server string) (*http.Client, error) { + if server != "" { return newRemote(server, http.DefaultTransport) } @@ -47,7 +43,7 @@ func Connect() (*http.Client, error) { func newRemote(server string, base http.RoundTripper) (*http.Client, error) { parsed, err := url.Parse(server) if err != nil || parsed.Scheme != "https" || parsed.Host == "" { - return nil, fmt.Errorf("%s must be an https URL: %w", serverVar, + return nil, fmt.Errorf("the server must be an https URL: %w", errors.Join(err, fmt.Errorf("got %q", server))) } diff --git a/internal/agentdoor/remote_test.go b/internal/agentdoor/remote_test.go index fd6f9c2..c74dfda 100644 --- a/internal/agentdoor/remote_test.go +++ b/internal/agentdoor/remote_test.go @@ -45,9 +45,8 @@ func TestTheServerMustBeAnHTTPSURL(t *testing.T) { func TestConnectChoosesTheSocketOrTheServer(t *testing.T) { t.Setenv("NUDGE_SOCKET", "/tmp/nudge-test.sock") - t.Setenv("NUDGE_SERVER", "") - client, err := Connect() + client, err := Connect("") if err != nil { t.Fatal(err) } @@ -56,9 +55,7 @@ func TestConnectChoosesTheSocketOrTheServer(t *testing.T) { t.Errorf("transport = %T, want the socket's", client.Transport) } - t.Setenv("NUDGE_SERVER", "https://host.tailnet.ts.net:8443") - - if client, err = Connect(); err != nil { + if client, err = Connect("https://host.tailnet.ts.net:8443"); err != nil { t.Fatal(err) } @@ -66,9 +63,7 @@ func TestConnectChoosesTheSocketOrTheServer(t *testing.T) { t.Errorf("transport = %T, want the remote's", client.Transport) } - t.Setenv("NUDGE_SERVER", "http://insecure") - - if _, err := Connect(); err == nil { + if _, err := Connect("http://insecure"); err == nil { t.Error("an http URL gave no error") } } diff --git a/internal/cli/ask.go b/internal/cli/ask.go index 25e3a24..d9479ae 100644 --- a/internal/cli/ask.go +++ b/internal/cli/ask.go @@ -67,7 +67,7 @@ func send(ctx context.Context, question agentdoor.Question) (agentdoor.Answer, e question.Project = project - client, err := agentdoor.Connect() + client, err := connect() if err != nil { return agentdoor.Answer{}, err } diff --git a/internal/cli/connect_internal_test.go b/internal/cli/connect_internal_test.go new file mode 100644 index 0000000..d6e16a5 --- /dev/null +++ b/internal/cli/connect_internal_test.go @@ -0,0 +1,59 @@ +package cli + +import ( + "net/http" + "os" + "path/filepath" + "testing" +) + +func TestConnectTakesTheServerFromTheVariableThenTheFile(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config")) + t.Setenv("NUDGE_SOCKET", filepath.Join(home, "agent.sock")) + t.Setenv("NUDGE_SERVER", "") + + config, err := os.UserConfigDir() + if err != nil { + t.Fatal(err) + } + + if err := os.MkdirAll(filepath.Dir(settingsFile(config)), 0o700); err != nil { + t.Fatal(err) + } + + viaSocket := func() bool { + client, err := connect() + if err != nil { + t.Fatal(err) + } + + _, socket := client.Transport.(*http.Transport) + + return socket + } + + if !viaSocket() { + t.Error("with no server set, connect didn't use the socket") + } + + writeFile(t, settingsFile(config), `{"server": "https://nudge.tailnet.ts.net"}`) + + if viaSocket() { + t.Error("with the file's server, connect used the socket") + } + + t.Setenv("NUDGE_SERVER", "http://insecure") + + if _, err := connect(); err == nil { + t.Error("the variable should win over the file, and an http URL is refused") + } + + t.Setenv("NUDGE_SERVER", "") + writeFile(t, settingsFile(config), `{"server": 3}`) + + if _, err := connect(); err == nil { + t.Error("an invalid settings file gave no error") + } +} diff --git a/internal/cli/report.go b/internal/cli/report.go index c156177..b1e569b 100644 --- a/internal/cli/report.go +++ b/internal/cli/report.go @@ -13,7 +13,7 @@ import ( // fails with why. func reporting(ctx context.Context) mcptool.Report { project, projectErr := currentProject() - client, clientErr := agentdoor.Connect() + client, clientErr := connect() if err := errors.Join(projectErr, clientErr); err != nil { return func(context.Context, string) error { return err } diff --git a/internal/cli/settings.go b/internal/cli/settings.go index 42691c8..f01664c 100644 --- a/internal/cli/settings.go +++ b/internal/cli/settings.go @@ -3,9 +3,11 @@ package cli import ( "cmp" "fmt" + "net/http" "os" "path/filepath" + "github.com/codedogapp/nudge/internal/agentdoor" "github.com/codedogapp/nudge/internal/vapid" ) @@ -18,6 +20,7 @@ const ( securityVar = "NUDGE_SECURITY" subscriptionsVar = "NUDGE_SUBSCRIPTIONS" keyFileVar = "NUDGE_VAPID_KEY_FILE" + serverVar = "NUDGE_SERVER" defaultListen = "127.0.0.1:8700" ) @@ -72,3 +75,19 @@ func readSettings() (settings, error) { func missing(name, path, key string) error { return fmt.Errorf("%s is not set, and %s has no %q", name, path, key) } + +// connect returns the agent door's client: for the nudge serve at NUDGE_SERVER, else at the +// settings file's "server", else for the agent socket. +func connect() (*http.Client, error) { + config, err := os.UserConfigDir() + if err != nil { + return nil, fmt.Errorf("find the config folder: %w", err) + } + + file, err := readSettingsFile(settingsFile(config)) + if err != nil { + return nil, err + } + + return agentdoor.Connect(cmp.Or(os.Getenv(serverVar), file.Server)) +} diff --git a/internal/cli/settings_file.go b/internal/cli/settings_file.go index 86b2947..a9f7893 100644 --- a/internal/cli/settings_file.go +++ b/internal/cli/settings_file.go @@ -15,6 +15,7 @@ type fileSettings struct { Host string `json:"host"` Listen string `json:"listen"` Subscriptions string `json:"subscriptions"` + Server string `json:"server"` } // settingsFile is the settings file's path: nudge/settings.json in the user's config folder, diff --git a/internal/cli/settings_file_internal_test.go b/internal/cli/settings_file_internal_test.go index 96f23b8..d5f53ec 100644 --- a/internal/cli/settings_file_internal_test.go +++ b/internal/cli/settings_file_internal_test.go @@ -25,12 +25,14 @@ func TestReadSettingsFileReadsTheSettings(t *testing.T) { { name: "every key", data: `{"login": "user@example.com", "host": "machine.tailnet.ts.net", - "listen": "127.0.0.1:8799", "subscriptions": "/stand-in/subscriptions.json"}`, + "listen": "127.0.0.1:8799", "subscriptions": "/stand-in/subscriptions.json", + "server": "https://nudge.tailnet.ts.net"}`, want: fileSettings{ Login: "user@example.com", Host: "machine.tailnet.ts.net", Listen: "127.0.0.1:8799", Subscriptions: "/stand-in/subscriptions.json", + Server: "https://nudge.tailnet.ts.net", }, }, {