From e9993090e861e89eaadd2796a05fa1a5b105711c Mon Sep 17 00:00:00 2001 From: tjblackheart Date: Tue, 2 Jun 2026 10:56:51 +0200 Subject: [PATCH] feat: add a session timeout / auto close --- README.md | 5 ++++ cmd/andcli/main.go | 2 +- internal/config/ast.go | 2 +- internal/config/config.go | 30 ++++++++++++++++------- internal/config/config_test.go | 44 ++++++++++++++++++++++++++++------ internal/config/flags.go | 26 ++++++++++++-------- internal/model/model.go | 27 ++++++++++++++------- 7 files changed, 101 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index 80f6ba7..06942e1 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,10 @@ It's possible to adapt the application colors to your preferred color scheme. To It's possible to use andcli without the TUI and query a vault directly: `andcli --query 'something'`. The result will be either a string separated by " " as in ` ` or, in the case of multiple/no matches, an error. +## Session timeout + +andcli will auto-quit after an adjustable time to not leave juicy info exposed in the open. The default session timeout is set to 300s (5 minutes) and can be adjusted via the `--session-timeout` flag or set directly as `session_timeout` in the config file. It can be disabled by setting this value to 0. + ## Options ```text @@ -70,6 +74,7 @@ Options: -h, --help Show this help --passwd-stdin Read the vault password from stdin. If set, skips the password input. -q, --query string Query the vault directly and skip TUI functionality + --session-timeout int Auto-close after N seconds of inactivity (0=disabled) (default 300) --timeout int Timeout for decrypting the vault file, in seconds (default 5) -t, --type string Vault type (andotp, aegis, twofas, stratum, keepass, proton) -v, --version Prints version info and exits diff --git a/cmd/andcli/main.go b/cmd/andcli/main.go index d5b24b6..16df024 100644 --- a/cmd/andcli/main.go +++ b/cmd/andcli/main.go @@ -103,7 +103,7 @@ func open(cfg *config.Config) (vaults.Vault, error) { select { case <-done: - case <-time.After(cfg.Timeout()): + case <-time.After(cfg.DecryptionTimeoutD()): return nil, fmt.Errorf("decrypt: operation timed out. wrong type?") } diff --git a/internal/config/ast.go b/internal/config/ast.go index a975bff..00a972b 100644 --- a/internal/config/ast.go +++ b/internal/config/ast.go @@ -34,7 +34,7 @@ func replace(af *ast.File, pathStr string, value any) error { node, err := path.FilterFile(af) if err != nil { - return nil + return err } var newNode ast.Node diff --git a/internal/config/config.go b/internal/config/config.go index c334f78..d2d3ea7 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -16,11 +16,12 @@ import ( type ( Config struct { - File string `yaml:"file"` - Type vaults.Type `yaml:"type"` - ClipboardCmd string `yaml:"clipboard_cmd"` - Options *Opts `yaml:"options"` - Theme *Theme `yaml:"theme"` + File string `yaml:"file"` + Type vaults.Type `yaml:"type"` + ClipboardCmd string `yaml:"clipboard_cmd"` + Options *Opts `yaml:"options"` + Theme *Theme `yaml:"theme"` + SessionTimeout int `yaml:"session_timeout"` // path string passwordFromStdin bool @@ -58,7 +59,8 @@ func create(dir string) (*Config, error) { ShowUsernames: true, ShowTokens: false, }, - Theme: &DefaultTheme, + Theme: &DefaultTheme, + SessionTimeout: 300, } if err := cfg.mergeExisting(); err != nil { @@ -97,6 +99,7 @@ func (cfg Config) Persist() error { "$.file": cfg.File, "$.type": string(cfg.Type), "$.clipboard_cmd": cfg.ClipboardCmd, + "$.session_timeout": cfg.SessionTimeout, "$.options.show_usernames": cfg.Options.ShowUsernames, "$.options.show_tokens": cfg.Options.ShowTokens, "$.theme.base": cfg.Theme.Base, @@ -108,8 +111,13 @@ func (cfg Config) Persist() error { "$.theme.white": cfg.Theme.White, } + // fallback: write full file if a key is missing (old version) if err := apply(af, patch); err != nil { - return err + b, err := yaml.Marshal(cfg) + if err != nil { + return err + } + return os.WriteFile(cfg.path, b, 0o600) } return os.WriteFile(cfg.path, []byte(af.String()), 0o600) @@ -126,10 +134,15 @@ func (cfg Config) Query() string { } // Returns the timeout value as time.Duration. -func (cfg Config) Timeout() time.Duration { +func (cfg Config) DecryptionTimeoutD() time.Duration { return time.Duration(cfg.timeout * int(time.Second)) } +// Returns the session timeout value as time.Duration. +func (cfg Config) SessionTimeoutD() time.Duration { + return time.Duration(cfg.SessionTimeout * int(time.Second)) +} + // Reads an possibly existing config file and merges the content // into the current config. func (cfg *Config) mergeExisting() error { @@ -150,6 +163,7 @@ func (cfg *Config) mergeExisting() error { cfg.File = existing.File cfg.Type = existing.Type cfg.ClipboardCmd = existing.ClipboardCmd + cfg.SessionTimeout = existing.SessionTimeout if existing.Options != nil { cfg.Options = existing.Options diff --git a/internal/config/config_test.go b/internal/config/config_test.go index b5e834d..1a6610f 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -48,6 +48,12 @@ func TestConfig_mergeExisting(t *testing.T) { }, false, }, + { + "merges session timeout", + &Config{File: "", Type: "", ClipboardCmd: "", SessionTimeout: 0, path: path}, + &Config{File: "/tmp/test.json", Type: "aegis", ClipboardCmd: "", SessionTimeout: 600, path: path}, + false, + }, { "handles custom options", &Config{ @@ -172,7 +178,7 @@ func TestConfig_Persist(t *testing.T) { fname := filepath.Join(os.TempDir(), "andcli_test_config.yaml") defer os.RemoveAll(fname) - cfg := &Config{File: "test.json", Type: "aegis", ClipboardCmd: "/usr/bin/test", path: fname, dirty: true} + cfg := &Config{File: "test.json", Type: "aegis", ClipboardCmd: "/usr/bin/test", SessionTimeout: 300, path: fname, dirty: true} if err := cfg.Persist(); err != nil { t.Errorf("Config.Persist() error = %v, expected none", err) return @@ -206,6 +212,7 @@ func TestConfig_Persist_preservesComments(t *testing.T) { original := `# This is a comment at the top file: /path/to/vault.json # inline comment type: aegis +session_timeout: 300 # Comment before options options: show_usernames: true # another inline @@ -227,9 +234,10 @@ theme: } cfg := &Config{ - File: "/new/vault.json", - Type: vaults.Type("2fas"), - ClipboardCmd: "pbcopy", + File: "/new/vault.json", + Type: vaults.Type("2fas"), + ClipboardCmd: "pbcopy", + SessionTimeout: 300, Options: &Opts{ ShowUsernames: true, ShowTokens: true, @@ -275,6 +283,9 @@ theme: if strings.Contains(string(b), "aegis") { t.Error("type was not updated") } + if !strings.Contains(string(b), "session_timeout: 300") { + t.Error("session timeout was not persisted") + } } func Test_create(t *testing.T) { @@ -290,9 +301,10 @@ func Test_create(t *testing.T) { // default config want := &Config{ - File: abs, - Type: vaults.Type(*vtype), - ClipboardCmd: "", + File: abs, + Type: vaults.Type(*vtype), + SessionTimeout: 300, + ClipboardCmd: "", Options: &Opts{ ShowUsernames: true, ShowTokens: false, @@ -414,6 +426,24 @@ func TestConfig_Flags(t *testing.T) { } }, }, + { + "sets session timeout", + []string{"andcli", "--session-timeout", "600", "-t", "aegis", tmpFile.Name()}, + func(c *Config) { + if c.SessionTimeout != 600 { + t.Errorf("SessionTimeout = %d, want %d", c.SessionTimeout, 600) + } + }, + }, + { + "session timeout disabled on 0", + []string{"andcli", "--session-timeout", "0", "-t", "aegis", tmpFile.Name()}, + func(c *Config) { + if c.SessionTimeout != 0 { + t.Errorf("SessionTimeout = %d, want %d", c.SessionTimeout, 0) + } + }, + }, } for _, tt := range tests { diff --git a/internal/config/flags.go b/internal/config/flags.go index a2d3acb..d267286 100644 --- a/internal/config/flags.go +++ b/internal/config/flags.go @@ -12,15 +12,16 @@ import ( ) var ( - set = flag.NewFlagSet("default", flag.ExitOnError) - vfile = set.StringP("file", "f", "", "Path to the encrypted vault (deprecated: Pass the filename directly)") - vtype = set.StringP("type", "t", "", fmt.Sprintf("Vault type (%s)", vaults.StrTypes())) - cmd = set.StringP("clipboard-cmd", "c", "", "A custom clipboard command, including args (xclip, wl-copy, pbcopy etc.)") - pwstdin = set.Bool("passwd-stdin", false, "Read the vault password from stdin. If set, skips the password input.") - query = set.StringP("query", "q", "", "Query the vault directly and skip TUI functionality") - version = set.BoolP("version", "v", false, "Prints version info and exits") - timeout = set.Int("timeout", 5, "Timeout for decrypting the vault file, in seconds") - help = set.BoolP("help", "h", false, "Show this help") + set = flag.NewFlagSet("default", flag.ExitOnError) + vfile = set.StringP("file", "f", "", "Path to the encrypted vault (deprecated: Pass the filename directly)") + vtype = set.StringP("type", "t", "", fmt.Sprintf("Vault type (%s)", vaults.StrTypes())) + cmd = set.StringP("clipboard-cmd", "c", "", "A custom clipboard command, including args (xclip, wl-copy, pbcopy etc.)") + pwstdin = set.Bool("passwd-stdin", false, "Read the vault password from stdin. If set, skips the password input.") + query = set.StringP("query", "q", "", "Query the vault directly and skip TUI functionality") + version = set.BoolP("version", "v", false, "Prints version info and exits") + decryptionTimeout = set.Int("timeout", 5, "Timeout for decrypting the vault file, in seconds") + sessionTimeout = set.Int("session-timeout", 300, "Auto-close after N seconds of inactivity (0=disabled)") + help = set.BoolP("help", "h", false, "Show this help") ) // Parses given flags into the existing config. @@ -79,11 +80,16 @@ func (cfg *Config) parseFlags() error { cfg.dirty = true } - cfg.timeout = *timeout + cfg.timeout = *decryptionTimeout if cfg.timeout <= 0 { cfg.timeout = 5 } + if set.Changed("session-timeout") { + cfg.SessionTimeout = max(*sessionTimeout, 0) + cfg.dirty = true + } + return nil } diff --git a/internal/model/model.go b/internal/model/model.go index c6d6898..bfd1121 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -18,10 +18,12 @@ import ( type ( Model struct { - list list.Model - state *appState - style *appStyle - cb *clipboard.Clipboard + list list.Model + state *appState + style *appStyle + cb *clipboard.Clipboard + lastActivity time.Time + sessionTimeout time.Duration } appState struct { @@ -60,10 +62,12 @@ func New(entries []vaults.Entry, cfg *config.Config) Model { dlg := &itemDelegate{style, state} m := Model{ - list: initList(items, dlg, title), - state: state, - style: style, - cb: clipboard.New(cfg.ClipboardCmd), + list: initList(items, dlg, title), + state: state, + style: style, + cb: clipboard.New(cfg.ClipboardCmd), + sessionTimeout: cfg.SessionTimeoutD(), + lastActivity: time.Now(), } m.updateToken() @@ -80,6 +84,9 @@ func (m Model) Init() tea.Cmd { func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyMsg: + // resets on each keypress + m.lastActivity = time.Now() + if m.list.FilterState() == list.Filtering { break } @@ -104,6 +111,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } case tickMsg: + if m.sessionTimeout > 0 && time.Since(m.lastActivity) > m.sessionTimeout { + return m, tea.Quit + } + m.updateToken() return m, tick()