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
41 changes: 31 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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 <token>`, 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

Expand Down
93 changes: 81 additions & 12 deletions cmd/waggle/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ package main

import (
"context"
"errors"
"flag"
"fmt"
"log/slog"
Expand Down Expand Up @@ -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())
Expand All @@ -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 {
Expand Down Expand Up @@ -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.
Expand Down
20 changes: 16 additions & 4 deletions examples/daemon.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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 <token>` 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 <token>`, 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
21 changes: 19 additions & 2 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 {
Expand Down
51 changes: 42 additions & 9 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -176,32 +176,65 @@ 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) {
cases := map[string]struct {
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",
},
Expand All @@ -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) {
Expand Down
Loading
Loading