fix(config): add keyring fallback for bot token
This commit is contained in:
parent
5971c9861c
commit
624b771894
@ -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
|
||||
|
||||
20
README.md
20
README.md
@ -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
|
||||
|
||||
|
||||
5
SPEC.md
5
SPEC.md
@ -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
3
go.mod
@ -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
8
go.sum
@ -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=
|
||||
|
||||
@ -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())
|
||||
}
|
||||
|
||||
@ -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
|
||||
})
|
||||
}
|
||||
|
||||
@ -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 ")
|
||||
|
||||
Loading…
Reference in New Issue
Block a user