fix(config): add keyring fallback for bot token

This commit is contained in:
Peter Steinberger 2026-05-01 11:27:52 +01:00
parent 5971c9861c
commit 624b771894
No known key found for this signature in database
8 changed files with 149 additions and 11 deletions

View File

@ -6,6 +6,7 @@ All notable changes to `discrawl` will be documented in this file.
### Fixes
- Added OS keyring fallback for Discord bot-token resolution, keeping env as the first source and documenting the default keyring item. (#17)
- Documented and covered FTS query normalization so operator-like search terms stay parameterized and quoted before SQLite `MATCH`. Thanks @mvanhorn.
## 0.6.2 - 2026-05-01

View File

@ -60,6 +60,7 @@ Without those intents/permissions, `sync`, `tail`, member snapshots, or message
Token resolution:
1. `DISCORD_BOT_TOKEN` or the configured `discord.token_env`
2. OS keyring item `discrawl` / `discord_bot_token`, or the configured keyring service/account
`discrawl` accepts either raw token text or a value prefixed with `Bot `. It normalizes that automatically.
@ -79,6 +80,21 @@ export DISCORD_BOT_TOKEN="your-bot-token"
Then reload your shell before running `discrawl`.
If you prefer the OS keyring, keep the token out of config and store it in the default keyring item:
```bash
# macOS Keychain
security add-generic-password -U -s discrawl -a discord_bot_token -w "$DISCORD_BOT_TOKEN"
# Linux Secret Service / libsecret
printf %s "$DISCORD_BOT_TOKEN" | secret-tool store --label="discrawl Discord bot token" service discrawl username discord_bot_token
# Windows Credential Manager
cmdkey /generic:discrawl:discord_bot_token /user:discord_bot_token /pass:%DISCORD_BOT_TOKEN%
```
Set `discord.token_source = "keyring"` if you want to require keyring lookup instead of env-first fallback.
Default runtime paths:
- config: `~/.discrawl/config.toml`
@ -538,6 +554,8 @@ log_dir = "~/.discrawl/logs"
[discord]
token_source = "env" # use "none" for Git-only read access
token_env = "DISCORD_BOT_TOKEN"
token_keyring_service = "discrawl"
token_keyring_account = "discord_bot_token"
[sync]
source = "both" # use "discord" for bot-only sync or "wiretap" for desktop-cache-only import
@ -575,6 +593,7 @@ Config override rules:
- `--config` beats everything
- `DISCRAWL_CONFIG` overrides the default config path
- `discord.token_source = "none"` disables live Discord access for Git-only readers
- `discord.token_source = "keyring"` skips env lookup and reads only the configured OS keyring item
- `DISCRAWL_NO_AUTO_UPDATE=1` disables Git snapshot auto-update for read commands in one process, useful for report jobs that already imported a fresh backup.
## Embeddings
@ -642,6 +661,7 @@ Set `sync.attachment_text = false` if you want to keep attachment metadata and f
- do not commit bot tokens or API keys
- default config lives in your home directory, not inside the repo
- prefer env vars or the OS keyring for bot tokens
- CI runs secret scanning with `gitleaks`
- `doctor` reports token source, not token contents

View File

@ -51,7 +51,7 @@ These are settled unless the user explicitly changes them:
- DB location: `~/.discrawl/discrawl.db`
- cache dir: `~/.discrawl/cache/`
- log dir: `~/.discrawl/logs/`
- token source: `DISCORD_BOT_TOKEN` or configured env var
- token source: `DISCORD_BOT_TOKEN` or configured env var, then optional OS keyring fallback
- guild model: one guild in CLI UX, multi-guild-ready schema
- search: hybrid, with FTS first and embeddings optional
- embedding provider: OpenAI
@ -563,6 +563,8 @@ log_dir = "~/.discrawl/logs"
[discord]
token_source = "env"
token_env = "DISCORD_BOT_TOKEN"
token_keyring_service = "discrawl"
token_keyring_account = "discord_bot_token"
[sync]
concurrency = 4
@ -603,6 +605,7 @@ Do not:
Do:
- load bot token from env
- fall back to the configured OS keyring item when env is empty
- load OpenAI key from env
- redact secrets in debug and doctor output

3
go.mod
View File

@ -7,14 +7,17 @@ require (
github.com/gorilla/websocket v1.5.3
github.com/pelletier/go-toml/v2 v2.3.0
github.com/stretchr/testify v1.11.1
github.com/zalando/go-keyring v0.2.8
golang.org/x/sys v0.43.0
golang.org/x/text v0.36.0
modernc.org/sqlite v1.50.0
)
require (
github.com/danieljoos/wincred v1.2.3 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/godbus/dbus/v5 v5.2.2 // indirect
github.com/google/pprof v0.0.0-20260402051712-545e8a4df936 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/kr/pretty v0.3.1 // indirect

8
go.sum
View File

@ -1,10 +1,14 @@
github.com/bwmarrin/discordgo v0.29.0 h1:FmWeXFaKUwrcL3Cx65c20bTRW+vOb6k8AnaP+EgjDno=
github.com/bwmarrin/discordgo v0.29.0/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ=
github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
github.com/google/pprof v0.0.0-20260402051712-545e8a4df936 h1:EwtI+Al+DeppwYX2oXJCETMO23COyaKGP6fHVpkpWpg=
github.com/google/pprof v0.0.0-20260402051712-545e8a4df936/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@ -34,8 +38,12 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs=
github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0=
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=

View File

@ -13,8 +13,10 @@ import (
)
const (
DefaultConfigEnv = "DISCRAWL_CONFIG"
DefaultTokenEnv = "DISCORD_BOT_TOKEN"
DefaultConfigEnv = "DISCRAWL_CONFIG"
DefaultTokenEnv = "DISCORD_BOT_TOKEN"
DefaultTokenKeyringService = "discrawl"
DefaultTokenKeyringAccount = "discord_bot_token"
)
type Config struct {
@ -33,8 +35,10 @@ type Config struct {
}
type DiscordConfig struct {
TokenSource string `toml:"token_source"`
TokenEnv string `toml:"token_env"`
TokenSource string `toml:"token_source"`
TokenEnv string `toml:"token_env"`
TokenKeyringService string `toml:"token_keyring_service"`
TokenKeyringAccount string `toml:"token_keyring_account"`
}
type DesktopConfig struct {
@ -90,8 +94,10 @@ func Default() Config {
LogDir: filepath.Join(base, "logs"),
DefaultGuildID: "",
Discord: DiscordConfig{
TokenSource: "env",
TokenEnv: DefaultTokenEnv,
TokenSource: "env",
TokenEnv: DefaultTokenEnv,
TokenKeyringService: DefaultTokenKeyringService,
TokenKeyringAccount: DefaultTokenKeyringAccount,
},
Desktop: DesktopConfig{
Path: defaultDiscordDesktopPath(home),
@ -204,12 +210,22 @@ func (c *Config) Normalize() error {
c.LogDir = def.LogDir
}
}
c.Discord.TokenSource = strings.ToLower(strings.TrimSpace(c.Discord.TokenSource))
c.Discord.TokenEnv = strings.TrimSpace(c.Discord.TokenEnv)
c.Discord.TokenKeyringService = strings.TrimSpace(c.Discord.TokenKeyringService)
c.Discord.TokenKeyringAccount = strings.TrimSpace(c.Discord.TokenKeyringAccount)
if c.Discord.TokenSource == "" {
c.Discord.TokenSource = "env"
}
if c.Discord.TokenEnv == "" {
c.Discord.TokenEnv = DefaultTokenEnv
}
if c.Discord.TokenKeyringService == "" {
c.Discord.TokenKeyringService = DefaultTokenKeyringService
}
if c.Discord.TokenKeyringAccount == "" {
c.Discord.TokenKeyringAccount = DefaultTokenKeyringAccount
}
if c.Desktop.Path == "" {
c.Desktop.Path = defaultDiscordDesktopPath(homeDir())
}

View File

@ -1,12 +1,14 @@
package config
import (
"errors"
"os"
"path/filepath"
"runtime"
"testing"
"github.com/stretchr/testify/require"
"github.com/zalando/go-keyring"
)
func TestNormalizeFillsDefaults(t *testing.T) {
@ -17,6 +19,8 @@ func TestNormalizeFillsDefaults(t *testing.T) {
require.Equal(t, 1, cfg.Version)
require.Equal(t, "env", cfg.Discord.TokenSource)
require.Equal(t, DefaultTokenEnv, cfg.Discord.TokenEnv)
require.Equal(t, DefaultTokenKeyringService, cfg.Discord.TokenKeyringService)
require.Equal(t, DefaultTokenKeyringAccount, cfg.Discord.TokenKeyringAccount)
require.Equal(t, defaultSyncConcurrency(), cfg.Sync.Concurrency)
require.GreaterOrEqual(t, cfg.Sync.Concurrency, 8)
require.LessOrEqual(t, cfg.Sync.Concurrency, 32)
@ -62,6 +66,41 @@ func TestResolveDiscordTokenFromEnv(t *testing.T) {
require.Equal(t, "env", token.Source)
}
func TestResolveDiscordTokenFallsBackToKeyring(t *testing.T) {
cfg := Default()
t.Setenv(DefaultTokenEnv, "")
stubDiscordTokenKeyring(t, func(service, account string) (string, error) {
require.Equal(t, DefaultTokenKeyringService, service)
require.Equal(t, DefaultTokenKeyringAccount, account)
return "Bot keyring-token", nil
})
token, err := ResolveDiscordToken(cfg)
require.NoError(t, err)
require.Equal(t, "keyring-token", token.Token)
require.Equal(t, "keyring", token.Source)
require.Equal(t, "discrawl/discord_bot_token", token.Path)
}
func TestResolveDiscordTokenFromKeyringSource(t *testing.T) {
cfg := Default()
cfg.Discord.TokenSource = "keyring"
cfg.Discord.TokenKeyringService = " custom-service "
cfg.Discord.TokenKeyringAccount = " custom-account "
t.Setenv(DefaultTokenEnv, "ignored-env-token")
stubDiscordTokenKeyring(t, func(service, account string) (string, error) {
require.Equal(t, "custom-service", service)
require.Equal(t, "custom-account", account)
return "custom-keyring-token", nil
})
token, err := ResolveDiscordToken(cfg)
require.NoError(t, err)
require.Equal(t, "custom-keyring-token", token.Token)
require.Equal(t, "keyring", token.Source)
require.Equal(t, "custom-service/custom-account", token.Path)
}
func TestResolveDiscordTokenFromCustomEnv(t *testing.T) {
cfg := Default()
cfg.Discord.TokenEnv = "DISCRAWL_TEST_DISCORD_TOKEN"
@ -76,9 +115,13 @@ func TestResolveDiscordTokenFromCustomEnv(t *testing.T) {
func TestResolveDiscordTokenRequiresEnvValue(t *testing.T) {
cfg := Default()
t.Setenv(DefaultTokenEnv, "")
stubDiscordTokenKeyring(t, func(_, _ string) (string, error) {
return "", keyring.ErrNotFound
})
_, err := ResolveDiscordToken(cfg)
require.ErrorContains(t, err, `discord token not found in environment variable "DISCORD_BOT_TOKEN"`)
require.ErrorContains(t, err, `discord token not found in environment variable "DISCORD_BOT_TOKEN" or keyring item "discrawl"/"discord_bot_token"`)
require.True(t, errors.Is(err, keyring.ErrNotFound))
}
func TestResolveDiscordTokenDisabled(t *testing.T) {
@ -258,6 +301,9 @@ func TestResolvePathUsesEnv(t *testing.T) {
func TestConfigErrorsAndBackupFallback(t *testing.T) {
dir := t.TempDir()
t.Setenv(DefaultTokenEnv, "")
stubDiscordTokenKeyring(t, func(_, _ string) (string, error) {
return "", keyring.ErrNotFound
})
_, err := ExpandPath("")
require.Error(t, err)
@ -271,3 +317,12 @@ func TestConfigErrorsAndBackupFallback(t *testing.T) {
_, err = ResolveDiscordToken(cfg)
require.Error(t, err)
}
func stubDiscordTokenKeyring(t *testing.T, get func(service, account string) (string, error)) {
t.Helper()
old := discordTokenKeyringGet
discordTokenKeyringGet = get
t.Cleanup(func() {
discordTokenKeyringGet = old
})
}

View File

@ -5,8 +5,12 @@ import (
"fmt"
"os"
"strings"
"github.com/zalando/go-keyring"
)
var discordTokenKeyringGet = keyring.Get
func ResolveDiscordToken(cfg Config) (TokenResolution, error) {
if err := cfg.Normalize(); err != nil {
return TokenResolution{}, err
@ -16,15 +20,43 @@ func ResolveDiscordToken(cfg Config) (TokenResolution, error) {
return TokenResolution{}, errors.New("discord token disabled by config")
case "env":
envToken := NormalizeBotToken(os.Getenv(cfg.Discord.TokenEnv))
if envToken == "" {
return TokenResolution{}, fmt.Errorf("discord token not found in environment variable %q", cfg.Discord.TokenEnv)
if envToken != "" {
return TokenResolution{Token: envToken, Source: "env", Path: cfg.Discord.TokenEnv}, nil
}
return TokenResolution{Token: envToken, Source: "env", Path: cfg.Discord.TokenEnv}, nil
token, err := resolveDiscordTokenFromKeyring(cfg)
if err == nil {
return token, nil
}
return TokenResolution{}, fmt.Errorf(
"discord token not found in environment variable %q or keyring item %q/%q: %w",
cfg.Discord.TokenEnv,
cfg.Discord.TokenKeyringService,
cfg.Discord.TokenKeyringAccount,
err,
)
case "keyring":
return resolveDiscordTokenFromKeyring(cfg)
default:
return TokenResolution{}, fmt.Errorf("unsupported discord token_source %q", cfg.Discord.TokenSource)
}
}
func resolveDiscordTokenFromKeyring(cfg Config) (TokenResolution, error) {
raw, err := discordTokenKeyringGet(cfg.Discord.TokenKeyringService, cfg.Discord.TokenKeyringAccount)
if err != nil {
return TokenResolution{}, err
}
token := NormalizeBotToken(raw)
if token == "" {
return TokenResolution{}, errors.New("keyring item is empty")
}
return TokenResolution{
Token: token,
Source: "keyring",
Path: cfg.Discord.TokenKeyringService + "/" + cfg.Discord.TokenKeyringAccount,
}, nil
}
func NormalizeBotToken(raw string) string {
raw = strings.TrimSpace(raw)
raw = strings.TrimPrefix(raw, "Bot ")