Skip to content
Merged
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: 1 addition & 1 deletion docs/plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<tag>` 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 |
Expand Down
2 changes: 1 addition & 1 deletion docs/setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
14 changes: 5 additions & 9 deletions internal/agentdoor/remote.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
}

Expand All @@ -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)))
}

Expand Down
11 changes: 3 additions & 8 deletions internal/agentdoor/remote_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand All @@ -56,19 +55,15 @@ 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)
}

if _, ok := client.Transport.(remote); !ok {
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")
}
}
2 changes: 1 addition & 1 deletion internal/cli/ask.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
59 changes: 59 additions & 0 deletions internal/cli/connect_internal_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
2 changes: 1 addition & 1 deletion internal/cli/report.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
19 changes: 19 additions & 0 deletions internal/cli/settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand All @@ -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"
)

Expand Down Expand Up @@ -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))
}
1 change: 1 addition & 0 deletions internal/cli/settings_file.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 3 additions & 1 deletion internal/cli/settings_file_internal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
},
},
{
Expand Down
Loading