diff --git a/README.md b/README.md index 8ebba15..1f85c08 100644 --- a/README.md +++ b/README.md @@ -236,9 +236,21 @@ POST /api/messages/{id}/replay[?destination={destID}] ``` The web UI (HTMX + Flowbite, embedded and offline) is served at `/` and is -the full-control surface; the TUI (`waggle tui [-addr host:port] [-token -…]`) is a read-only observer that attaches to a running daemon over the -same API and event stream. +the full-control surface; the TUI is a read-only observer that attaches to +a running daemon over the same API and event stream: + +```sh +waggle tui # reads ./daemon.yaml for the address and credentials +waggle tui -config examples/daemon.yaml # or point it at one elsewhere +waggle tui -addr 127.0.0.1:8420 -token ... # or supply them directly +waggle tui -addr 127.0.0.1:8420 -user ops -password ... +``` + +It takes whichever credential the daemon is configured with, from the +config file, the flags, or `$WAGGLE_TOKEN` / `$WAGGLE_USER` / +`$WAGGLE_PASSWORD`. Flags win over the environment, which wins over the +config. A rejected credential says which kind was tried rather than just +`401`. ## Configuration @@ -248,13 +260,22 @@ channel file). Unknown keys are load-time errors. `retention: -1` keeps messages forever; queue-referenced messages are never pruned. **Auth.** The API and web UI are the full-control surface, so every route -requires credentials: `auth.token` is accepted as `Authorization: Bearer` -and as the basic-auth password with any user name (browsers prompt for -it), and `auth.basicUser`/`auth.basicPassword` add a dedicated login. -The daemon listens on `127.0.0.1:8420` by default and refuses to bind a -non-loopback address without credentials unless `auth.disabled: true`. -State-changing requests that carry a browser `Origin` or `Sec-Fetch-Site` -header must be same-origin. +requires credentials. There are no default credentials — nothing is +generated for you — and a daemon with none configured would reject every +request, so one of these is required or startup fails with a message +naming all three: + +| setting | how you authenticate | +| --- | --- | +| `auth.token` | `Authorization: Bearer `, and also the basic-auth **password with any user name**, so a browser prompt accepts it | +| `auth.basicUser` + `auth.basicPassword` | a named login for the browser prompt (both halves required) | +| `auth.disabled: true` | serve unauthenticated — only behind an authenticating reverse proxy, or on an isolated host | + +The daemon listens on `127.0.0.1:8420` by default. `auth.disabled` on a +non-loopback address is allowed but warns loudly at startup, since anyone +who can reach the port controls every channel. State-changing requests +that carry a browser `Origin` or `Sec-Fetch-Site` header must be +same-origin. ## Development diff --git a/cmd/waggle/main.go b/cmd/waggle/main.go index 0eee50c..551df1a 100644 --- a/cmd/waggle/main.go +++ b/cmd/waggle/main.go @@ -9,6 +9,7 @@ package main import ( "context" + "errors" "flag" "fmt" "log/slog" @@ -61,33 +62,69 @@ func main() { } // runTUI attaches the read-only observer TUI to a running daemon over its -// HTTP API and event stream. The address and token default to the daemon -// config so `waggle tui` next to `waggle daemon` just works. +// HTTP API and event stream. Credentials default to the daemon config, so +// `waggle tui` next to `waggle daemon` just works for both a token and a +// basicUser/basicPassword login; flags and the environment override it. func runTUI(args []string) int { fs := flag.NewFlagSet("tui", flag.ExitOnError) - configPath := fs.String("config", "daemon.yaml", "path to daemon config (for the listen address and token)") + configPath := fs.String("config", "daemon.yaml", "path to daemon config (for the listen address and credentials)") addr := fs.String("addr", "", "daemon API address, e.g. 127.0.0.1:8420 (default: the config's listen address)") token := fs.String("token", "", "API token (default: auth.token from the config, or $WAGGLE_TOKEN)") + user := fs.String("user", "", "basic-auth user (default: auth.basicUser from the config, or $WAGGLE_USER)") + password := fs.String("password", "", "basic-auth password (default: auth.basicPassword from the config, or $WAGGLE_PASSWORD)") _ = fs.Parse(args) - cfg, err := config.LoadDaemon(*configPath) - if err != nil { - fmt.Fprintf(os.Stderr, "loading daemon config: %v\n", err) - return 1 + // A config is a convenience here, not a requirement: the TUI needs only + // an address and credentials, and may well be pointed at a daemon whose + // config lives on another host. So a missing default config falls back + // to the built-in defaults, while an explicit -config that is not there + // is a typo worth reporting rather than silently ignoring. + explicitConfig := false + fs.Visit(func(f *flag.Flag) { + if f.Name == "config" { + explicitConfig = true + } + }) + cfg := config.DefaultDaemon() + if _, statErr := os.Stat(*configPath); statErr != nil { + if explicitConfig { + fmt.Fprintf(os.Stderr, "reading daemon config: %v\n", statErr) + return 1 + } + } else { + loaded, err := config.LoadDaemon(*configPath) + if err != nil { + fmt.Fprintf(os.Stderr, "loading daemon config: %v\n", err) + return 1 + } + cfg = loaded } + if *addr == "" { *addr = dialableAddr(cfg.Listen) } if *token == "" { - *token = cfg.Auth.Token + *token = firstNonEmpty(cfg.Auth.Token, os.Getenv("WAGGLE_TOKEN")) } - if *token == "" { - *token = os.Getenv("WAGGLE_TOKEN") + if *user == "" { + *user = firstNonEmpty(cfg.Auth.BasicUser, os.Getenv("WAGGLE_USER")) + } + if *password == "" { + *password = firstNonEmpty(cfg.Auth.BasicPassword, os.Getenv("WAGGLE_PASSWORD")) + } + + backend := &tui.HTTPBackend{ + BaseURL: "http://" + *addr, + Token: *token, + BasicUser: *user, + BasicPassword: *password, } - backend := &tui.HTTPBackend{BaseURL: "http://" + *addr, Token: *token} // Fail fast with a readable message rather than an empty screen. if _, err := backend.ChannelSummaries(context.Background()); err != nil { fmt.Fprintf(os.Stderr, "connecting to the daemon at %s: %v\n", *addr, err) + if errors.Is(err, tui.ErrUnauthorized) { + fmt.Fprint(os.Stderr, credentialHelp(*token != "", *user != "")) + } return 1 } p := tea.NewProgram(tui.New(backend), tea.WithAltScreen()) @@ -98,6 +135,30 @@ func runTUI(args []string) int { return 0 } +// credentialHelp explains which credentials the TUI sent and how to supply +// the right ones, since a bare 401 does not say whether the problem is a +// missing token, a missing login, or a wrong password. +func credentialHelp(sentToken, sentUser bool) string { + switch { + case sentToken: + return "\nThe token was rejected. Check auth.token in the daemon config, -token, or $WAGGLE_TOKEN.\n" + case sentUser: + return "\nThe basic-auth login was rejected. Check auth.basicUser and auth.basicPassword in the\ndaemon config, -user and -password, or $WAGGLE_USER and $WAGGLE_PASSWORD.\n" + default: + return "\nNo credentials were sent. Point -config at the daemon config, or pass -token\n(for auth.token) or -user and -password (for auth.basicUser/auth.basicPassword).\n" + } +} + +// firstNonEmpty returns the first non-empty argument, or "". +func firstNonEmpty(vals ...string) string { + for _, v := range vals { + if v != "" { + return v + } + } + return "" +} + // dialableAddr turns a listen address into one a client can connect to: a // bare port or wildcard host means loopback. func dialableAddr(listen string) string { @@ -173,7 +234,15 @@ func runDaemon(args []string) int { Log: log, } if cfg.Auth.Disabled { - log.Warn("http api authentication is disabled", "addr", cfg.Listen) + if cfg.ServesLoopbackOnly() { + log.Warn("http api authentication is disabled", "addr", cfg.Listen) + } else { + // Reachable from the network with no credentials: legitimate + // only behind something that authenticates for us. + log.Warn("http api authentication is disabled on a non-loopback address; "+ + "anyone who can reach this port can control every channel", + "addr", cfg.Listen) + } } // No WriteTimeout: the SSE endpoints stream indefinitely, and the API // applies a per-response deadline to everything else. diff --git a/examples/daemon.yaml b/examples/daemon.yaml index 14e62be..e222f81 100644 --- a/examples/daemon.yaml +++ b/examples/daemon.yaml @@ -4,9 +4,21 @@ dataDir: data # SQLite message store lives here channelsDir: channels # one YAML file per channel hotReload: true # watch channel scripts for changes -# Credentials for the API and web UI. The token works as -# `Authorization: Bearer ` and as the basic-auth password (any user -# name) so the browser prompt accepts it too. Listening on a non-loopback -# address requires auth; `disabled: true` opts out explicitly. +# Credentials for the API and web UI. Some form of auth is required: a +# daemon with no credentials would reject every request, so waggle refuses +# to start rather than lock you out. Pick one of: +# +# token `Authorization: Bearer `, and also accepted as the +# basic-auth password with ANY user name, so the browser +# prompt works. This is what `waggle tui` uses by default. +# basicUser + A named login for the browser prompt. `waggle tui` reads +# basicPassword both from this file, or takes -user/-password. +# disabled Serve unauthenticated. Loopback only; refused on a +# non-loopback listen address. +# +# Override for the TUI with -token, or -user and -password, or the +# $WAGGLE_TOKEN / $WAGGLE_USER / $WAGGLE_PASSWORD environment variables. auth: token: change-me + # basicUser: ops + # basicPassword: a-long-random-password diff --git a/internal/config/config.go b/internal/config/config.go index 7f65411..a3de9ad 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -74,6 +74,13 @@ func LoadDaemon(path string) (Daemon, error) { raw, err := os.ReadFile(path) if err != nil { if os.IsNotExist(err) { + // Falling back to the defaults must not skip validation: the + // defaults configure no credentials, and a daemon serving with + // none rejects every request. Report that instead of starting + // an unusable server. + if verr := cfg.Validate(); verr != nil { + return cfg, fmt.Errorf("no config file at %s: %w", path, verr) + } return cfg, nil } return cfg, fmt.Errorf("config: %w", err) @@ -114,12 +121,22 @@ func (d *Daemon) Validate() error { if d.Auth.BasicPassword != "" && d.Auth.BasicUser == "" { return fmt.Errorf("auth: basicPassword requires basicUser") } - if !d.Auth.Enabled() && !d.Auth.Disabled && !isLoopback(d.Listen) { - return fmt.Errorf("listen %q is not a loopback address and no auth is configured: set auth.token (or auth.disabled: true to serve unauthenticated)", d.Listen) + // No credentials at all is refused everywhere, not just off-loopback: + // the API rejects every request when nothing is configured, so serving + // that way is a lockout rather than an open door. Saying so at startup + // beats a daemon that runs and 401s its own operator. + if !d.Auth.Enabled() && !d.Auth.Disabled { + return fmt.Errorf("auth: no credentials configured for %q: set auth.token, or auth.basicUser with auth.basicPassword, or auth.disabled: true to serve unauthenticated", d.Listen) } return nil } +// ServesLoopbackOnly reports whether the daemon's listen address binds only +// a loopback interface. Auth policy no longer varies by address, but an +// unauthenticated daemon reachable from the network deserves a louder +// warning than one bound to localhost. +func (d *Daemon) ServesLoopbackOnly() bool { return isLoopback(d.Listen) } + // isLoopback reports whether addr binds only a loopback interface. An empty // host (":8420") binds every interface and is not loopback. func isLoopback(addr string) bool { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 23034d0..63ddba8 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -176,13 +176,33 @@ destinations: } func TestLoadDaemonDefaults(t *testing.T) { + // The returned config carries the defaults even when the file is + // missing, so callers that tolerate its absence (the TUI) can still + // read the listen address off it. cfg, err := LoadDaemon(filepath.Join(t.TempDir(), "missing.yaml")) - if err != nil { - t.Fatal(err) - } if cfg.Listen != DefaultListen || cfg.ChannelsDir != "channels" { t.Errorf("defaults = %+v", cfg) } + // But a missing config is still an error, because the defaults alone + // configure no credentials and such a daemon rejects every request. + if err == nil { + t.Fatal("expected an error: the defaults configure no credentials") + } + for _, want := range []string{"no config file at", "no credentials configured"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("error %q does not mention %q", err, want) + } + } +} + +// A default config plus credentials is valid: nothing else is required to +// run a loopback daemon. +func TestDefaultsWithCredentialsAreValid(t *testing.T) { + cfg := DefaultDaemon() + cfg.Auth.Token = "secret" + if err := cfg.Validate(); err != nil { + t.Fatalf("defaults + token should validate: %v", err) + } } func TestLoadDaemonAuthPolicy(t *testing.T) { @@ -190,18 +210,31 @@ func TestLoadDaemonAuthPolicy(t *testing.T) { yaml string wantErr string }{ - "loopback without auth": {yaml: "listen: 127.0.0.1:9000\n"}, - "localhost without auth": {yaml: "listen: localhost:9000\n"}, - "ipv6 loopback without auth": {yaml: "listen: \"[::1]:9000\"\n"}, + // No credentials at all is refused everywhere: the API would 401 + // every request, including its own operator's. + "loopback without auth": { + yaml: "listen: 127.0.0.1:9000\n", + wantErr: "no credentials configured", + }, + "localhost without auth": { + yaml: "listen: localhost:9000\n", + wantErr: "no credentials configured", + }, "all interfaces without auth": { yaml: "listen: \":9000\"\n", - wantErr: "no auth is configured", + wantErr: "no credentials configured", }, "public address without auth": { yaml: "listen: 0.0.0.0:9000\n", - wantErr: "no auth is configured", + wantErr: "no credentials configured", }, + "loopback with token": {yaml: "listen: 127.0.0.1:9000\nauth: {token: secret}\n"}, + "loopback with basic auth": {yaml: "listen: 127.0.0.1:9000\nauth: {basicUser: ops, basicPassword: pw}\n"}, + "ipv6 loopback with token": {yaml: "listen: \"[::1]:9000\"\nauth: {token: secret}\n"}, + "loopback auth disabled": {yaml: "listen: 127.0.0.1:9000\nauth: {disabled: true}\n"}, "public address with token": {yaml: "listen: 0.0.0.0:9000\nauth: {token: secret}\n"}, + // disabled is the explicit opt-out at any address: it exists for + // daemons behind an authenticating reverse proxy. "public address auth disabled": { yaml: "listen: 0.0.0.0:9000\nauth: {disabled: true}\n", }, @@ -217,7 +250,7 @@ func TestLoadDaemonAuthPolicy(t *testing.T) { yaml: "auth: {basicPassword: pw}\n", wantErr: "basicPassword requires basicUser", }, - "empty listen falls back to default": {yaml: "listen: \"\"\n"}, + "empty listen falls back to default": {yaml: "listen: \"\"\nauth: {token: secret}\n"}, } for name, tc := range cases { t.Run(name, func(t *testing.T) { diff --git a/internal/tui/httpbackend.go b/internal/tui/httpbackend.go index 2504c28..0b8c997 100644 --- a/internal/tui/httpbackend.go +++ b/internal/tui/httpbackend.go @@ -4,6 +4,7 @@ import ( "bufio" "context" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -23,8 +24,13 @@ type HTTPBackend struct { // BaseURL is the daemon's API root, e.g. "http://127.0.0.1:8420". BaseURL string // Token is sent as a Bearer token; empty when the daemon has auth - // disabled. + // disabled or uses a basic-auth login instead. Token string + // BasicUser and BasicPassword authenticate against a daemon configured + // with auth.basicUser/auth.basicPassword. They are used only when Token + // is empty, since a token satisfies the daemon on its own. + BasicUser string + BasicPassword string // Client defaults to one with a 15s timeout. The event stream uses a // separate client without a timeout. Client *http.Client @@ -46,8 +52,11 @@ func (b *HTTPBackend) newRequest(ctx context.Context, path string, query url.Val if err != nil { return nil, err } - if b.Token != "" { + switch { + case b.Token != "": req.Header.Set("Authorization", "Bearer "+b.Token) + case b.BasicUser != "": + req.SetBasicAuth(b.BasicUser, b.BasicPassword) } req.Header.Set("Accept", "application/json") return req, nil @@ -74,21 +83,35 @@ func (b *HTTPBackend) get(ctx context.Context, path string, query url.Values, ou } // apiError turns a non-2xx response into an error carrying the API's own -// message when it sent one. +// message when it sent one, classified by status so callers can react to +// the kind of failure rather than parse a string. func apiError(resp *http.Response) error { body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) var payload struct { Error string `json:"error"` } + // The daemon reports failures as {"error": "..."}; anything else (a + // proxy's HTML page, say) leaves just the status line. + detail := resp.Status if json.Unmarshal(body, &payload) == nil && payload.Error != "" { - if resp.StatusCode == http.StatusNotFound { - return fmt.Errorf("%w: %s", store.ErrNotFound, payload.Error) - } - return fmt.Errorf("daemon: %s (%s)", payload.Error, resp.Status) + detail = fmt.Sprintf("%s (%s)", payload.Error, resp.Status) + } + switch resp.StatusCode { + case http.StatusNotFound: + return fmt.Errorf("%w: %s", store.ErrNotFound, detail) + case http.StatusUnauthorized: + return fmt.Errorf("%w: %s", ErrUnauthorized, detail) + case http.StatusForbidden: + return fmt.Errorf("daemon refused the request: %s", detail) } - return fmt.Errorf("daemon: %s", resp.Status) + return fmt.Errorf("daemon: %s", detail) } +// ErrUnauthorized reports that the daemon rejected the credentials, or the +// absence of them. Callers unwrap it to print credential guidance rather +// than a bare status line. +var ErrUnauthorized = errors.New("daemon rejected the credentials") + func (b *HTTPBackend) ChannelSummaries(ctx context.Context) ([]engine.ChannelSummary, error) { var out []engine.ChannelSummary if err := b.get(ctx, "/api/channels", nil, &out); err != nil { diff --git a/internal/tui/httpbackend_test.go b/internal/tui/httpbackend_test.go index f53d42d..e47f6df 100644 --- a/internal/tui/httpbackend_test.go +++ b/internal/tui/httpbackend_test.go @@ -4,6 +4,7 @@ import ( "context" "errors" "log/slog" + "net/http" "net/http/httptest" "path/filepath" "strings" @@ -138,3 +139,67 @@ func TestHTTPBackendUnreachable(t *testing.T) { t.Fatalf("err = %v", err) } } + +// TestHTTPBackendBasicAuth: a daemon configured with basicUser/basicPassword +// is reachable by the TUI, which was previously token-only. +func TestHTTPBackendBasicAuth(t *testing.T) { + var gotUser, gotPass, gotAuthHeader string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAuthHeader = r.Header.Get("Authorization") + u, p, ok := r.BasicAuth() + if !ok { + w.Header().Set("WWW-Authenticate", `Basic realm="waggle"`) + http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized) + return + } + gotUser, gotPass = u, p + if u != "ops" || p != "pw" { + http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized) + return + } + _, _ = w.Write([]byte(`[]`)) + })) + defer srv.Close() + + b := &tui.HTTPBackend{BaseURL: srv.URL, BasicUser: "ops", BasicPassword: "pw"} + if _, err := b.ChannelSummaries(context.Background()); err != nil { + t.Fatalf("basic auth should be accepted: %v", err) + } + if gotUser != "ops" || gotPass != "pw" { + t.Errorf("sent user/pass = %q/%q", gotUser, gotPass) + } + if !strings.HasPrefix(gotAuthHeader, "Basic ") { + t.Errorf("expected a Basic header, got %q", gotAuthHeader) + } + + // Wrong password surfaces as tui.ErrUnauthorized so the CLI can explain it. + bad := &tui.HTTPBackend{BaseURL: srv.URL, BasicUser: "ops", BasicPassword: "nope"} + _, err := bad.ChannelSummaries(context.Background()) + if !errors.Is(err, tui.ErrUnauthorized) { + t.Errorf("expected tui.ErrUnauthorized, got %v", err) + } + + // No credentials at all is also tui.ErrUnauthorized, not a generic failure. + none := &tui.HTTPBackend{BaseURL: srv.URL} + if _, err := none.ChannelSummaries(context.Background()); !errors.Is(err, tui.ErrUnauthorized) { + t.Errorf("expected tui.ErrUnauthorized with no credentials, got %v", err) + } +} + +// A token takes precedence over basic credentials: it satisfies the daemon +// on its own and is what the Bearer header is for. +func TestHTTPBackendTokenWins(t *testing.T) { + var header string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + header = r.Header.Get("Authorization") + _, _ = w.Write([]byte(`[]`)) + })) + defer srv.Close() + b := &tui.HTTPBackend{BaseURL: srv.URL, Token: "tok", BasicUser: "ops", BasicPassword: "pw"} + if _, err := b.ChannelSummaries(context.Background()); err != nil { + t.Fatal(err) + } + if header != "Bearer tok" { + t.Errorf("Authorization = %q, want the bearer token", header) + } +}