Compare commits

..

3 Commits

Author SHA1 Message Date
5d0c8cc20c fix: apply black background to banking TUI padding areas
Padding spaces (end-of-line and blank filler lines) were unstyled,
causing the terminal's default background to bleed through.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 00:55:33 +01:00
d226c32b9b fix: banking shell screen rendering artifacts and transfer panic
Fix rendering issues where content from previous screens bled through
when switching between views of different heights/widths:

- Pad every line to full terminal width (ANSI-aware) so shorter lines
  overwrite leftover content from previous renders
- Track terminal height via WindowSizeMsg and pad between content and
  footer to fill the screen
- Send tea.ClearScreen on all screen transitions for height changes
- Fix panic in transfer completion when routing number is < 4 chars

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 00:50:34 +01:00
86786c9d05 fix: clean up stale active sessions on startup
After an unclean shutdown, sessions could be left with disconnected_at
NULL, appearing permanently active. Add CloseActiveSessions to the Store
interface and call it at startup to close any leftover sessions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 00:16:48 +01:00
9 changed files with 196 additions and 35 deletions

View File

@@ -65,6 +65,13 @@ func run() error {
} }
defer store.Close() defer store.Close()
// Clean up sessions left active by a previous unclean shutdown.
if n, err := store.CloseActiveSessions(context.Background(), time.Now()); err != nil {
return fmt.Errorf("close stale sessions: %w", err)
} else if n > 0 {
logger.Info("closed stale sessions from previous run", "count", n)
}
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer cancel() defer cancel()

View File

@@ -7,6 +7,7 @@ import (
"time" "time"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"git.t-juice.club/torjus/oubliette/internal/shell" "git.t-juice.club/torjus/oubliette/internal/shell"
"git.t-juice.club/torjus/oubliette/internal/storage" "git.t-juice.club/torjus/oubliette/internal/storage"
@@ -520,7 +521,7 @@ func TestConfigString(t *testing.T) {
} }
func TestScreenFrame(t *testing.T) { func TestScreenFrame(t *testing.T) {
frame := screenFrame("TESTBANK", "TB-0001", "NORTHEAST", "content here") frame := screenFrame("TESTBANK", "TB-0001", "NORTHEAST", "content here", 0)
if !strings.Contains(frame, "TESTBANK FEDERAL RESERVE SYSTEM") { if !strings.Contains(frame, "TESTBANK FEDERAL RESERVE SYSTEM") {
t.Error("frame should contain bank name in header") t.Error("frame should contain bank name in header")
} }
@@ -531,3 +532,29 @@ func TestScreenFrame(t *testing.T) {
t.Error("frame should contain the content") t.Error("frame should contain the content")
} }
} }
func TestScreenFramePadsLines(t *testing.T) {
frame := screenFrame("TESTBANK", "TB-0001", "NE", "short\n", 0)
for i, line := range strings.Split(frame, "\n") {
w := lipgloss.Width(line)
if w > 0 && w < termWidth {
t.Errorf("line %d has visual width %d, want at least %d: %q", i, w, termWidth, line)
}
}
}
func TestScreenFramePadsToHeight(t *testing.T) {
short := screenFrame("TESTBANK", "TB-0001", "NE", "line1\nline2\n", 30)
lines := strings.Count(short, "\n")
// Total newlines should be at least height-1 (since the last line has no trailing newline).
if lines < 29 {
t.Errorf("padded frame has %d newlines, want at least 29 for height=30", lines)
}
// Without height, no padding.
noPad := screenFrame("TESTBANK", "TB-0001", "NE", "line1\nline2\n", 0)
noPadLines := strings.Count(noPad, "\n")
if noPadLines >= 29 {
t.Errorf("unpadded frame has %d newlines, should be much less than 29", noPadLines)
}
}

View File

@@ -33,6 +33,7 @@ type model struct {
state *bankState state *bankState
screen screen screen screen
quitting bool quitting bool
height int
login loginModel login loginModel
menu menuModel menu menuModel
@@ -76,8 +77,12 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, tea.Quit return m, tea.Quit
} }
if keyMsg, ok := msg.(tea.KeyMsg); ok { switch msg := msg.(type) {
if keyMsg.Type == tea.KeyCtrlC { case tea.WindowSizeMsg:
m.height = msg.Height
return m, nil
case tea.KeyMsg:
if msg.Type == tea.KeyCtrlC {
m.quitting = true m.quitting = true
return m, tea.Quit return m, tea.Quit
} }
@@ -130,7 +135,7 @@ func (m *model) View() string {
content = m.admin.View() content = m.admin.View()
} }
return screenFrame(m.bankName, m.terminalID, m.region, content) return screenFrame(m.bankName, m.terminalID, m.region, content, m.height)
} }
// --- Screen update handlers --- // --- Screen update handlers ---
@@ -142,8 +147,8 @@ func (m *model) updateLogin(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.login.stage == 2 { if m.login.stage == 2 {
// Login always succeeds — this is a honeypot. // Login always succeeds — this is a honeypot.
logCmd := logAction(m.sess, fmt.Sprintf("LOGIN acct=%s", m.login.accountNum), "ACCESS GRANTED") logCmd := logAction(m.sess, fmt.Sprintf("LOGIN acct=%s", m.login.accountNum), "ACCESS GRANTED")
m.goToMenu() clearCmd := m.goToMenu()
return m, tea.Batch(cmd, logCmd) return m, tea.Batch(cmd, logCmd, clearCmd)
} }
return m, cmd return m, cmd
} }
@@ -158,36 +163,36 @@ func (m *model) updateMenu(msg tea.Msg) (tea.Model, tea.Cmd) {
case "1": case "1":
m.screen = screenAccountSummary m.screen = screenAccountSummary
m.summary = newAccountSummaryModel(m.state.Accounts) m.summary = newAccountSummaryModel(m.state.Accounts)
return m, logAction(m.sess, "MENU 1", "ACCOUNT SUMMARY") return m, tea.Batch(tea.ClearScreen, logAction(m.sess, "MENU 1", "ACCOUNT SUMMARY"))
case "2": case "2":
m.screen = screenAccountDetail m.screen = screenAccountDetail
m.detail = newAccountDetailModel(m.state.Accounts, m.state.Transactions) m.detail = newAccountDetailModel(m.state.Accounts, m.state.Transactions)
return m, logAction(m.sess, "MENU 2", "ACCOUNT DETAIL") return m, tea.Batch(tea.ClearScreen, logAction(m.sess, "MENU 2", "ACCOUNT DETAIL"))
case "3": case "3":
m.screen = screenTransfer m.screen = screenTransfer
m.transfer = newTransferModel() m.transfer = newTransferModel()
return m, logAction(m.sess, "MENU 3", "WIRE TRANSFER") return m, tea.Batch(tea.ClearScreen, logAction(m.sess, "MENU 3", "WIRE TRANSFER"))
case "4": case "4":
m.screen = screenHistory m.screen = screenHistory
m.history = newHistoryModel(m.state.Accounts, m.state.Transactions) m.history = newHistoryModel(m.state.Accounts, m.state.Transactions)
return m, logAction(m.sess, "MENU 4", "TRANSACTION HISTORY") return m, tea.Batch(tea.ClearScreen, logAction(m.sess, "MENU 4", "TRANSACTION HISTORY"))
case "5": case "5":
m.screen = screenMessages m.screen = screenMessages
m.messages = newMessagesModel(m.state.Messages) m.messages = newMessagesModel(m.state.Messages)
return m, logAction(m.sess, "MENU 5", "SECURE MESSAGES") return m, tea.Batch(tea.ClearScreen, logAction(m.sess, "MENU 5", "SECURE MESSAGES"))
case "6": case "6":
m.screen = screenChangePin m.screen = screenChangePin
m.pinInput = "" m.pinInput = ""
m.pinStage = 0 m.pinStage = 0
m.pinMessage = "" m.pinMessage = ""
return m, logAction(m.sess, "MENU 6", "CHANGE PIN") return m, tea.Batch(tea.ClearScreen, logAction(m.sess, "MENU 6", "CHANGE PIN"))
case "7": case "7":
m.quitting = true m.quitting = true
return m, tea.Batch(logAction(m.sess, "LOGOUT", "SESSION ENDED"), tea.Quit) return m, tea.Batch(logAction(m.sess, "LOGOUT", "SESSION ENDED"), tea.Quit)
case "99", "admin", "ADMIN": case "99", "admin", "ADMIN":
m.screen = screenAdmin m.screen = screenAdmin
m.admin = newAdminModel() m.admin = newAdminModel()
return m, logAction(m.sess, "ADMIN ACCESS ATTEMPT", "ADMIN SCREEN SHOWN") return m, tea.Batch(tea.ClearScreen, logAction(m.sess, "ADMIN ACCESS ATTEMPT", "ADMIN SCREEN SHOWN"))
} }
// Invalid choice, reset. // Invalid choice, reset.
m.menu.choice = "" m.menu.choice = ""
@@ -197,7 +202,7 @@ func (m *model) updateMenu(msg tea.Msg) (tea.Model, tea.Cmd) {
func (m *model) updateAccountSummary(msg tea.Msg) (tea.Model, tea.Cmd) { func (m *model) updateAccountSummary(msg tea.Msg) (tea.Model, tea.Cmd) {
if _, ok := msg.(tea.KeyMsg); ok { if _, ok := msg.(tea.KeyMsg); ok {
m.goToMenu() return m, m.goToMenu()
} }
return m, nil return m, nil
} }
@@ -206,7 +211,7 @@ func (m *model) updateAccountDetail(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd var cmd tea.Cmd
m.detail, cmd = m.detail.Update(msg) m.detail, cmd = m.detail.Update(msg)
if m.detail.choice == "back" { if m.detail.choice == "back" {
m.goToMenu() return m, tea.Batch(cmd, m.goToMenu())
} }
return m, cmd return m, cmd
} }
@@ -218,8 +223,14 @@ func (m *model) updateTransfer(msg tea.Msg) (tea.Model, tea.Cmd) {
// Transfer cancelled. // Transfer cancelled.
if m.transfer.confirm == "cancelled" { if m.transfer.confirm == "cancelled" {
m.goToMenu() clearCmd := m.goToMenu()
return m, logAction(m.sess, "WIRE TRANSFER CANCELLED", "USER CANCELLED") return m, tea.Batch(clearCmd, logAction(m.sess, "WIRE TRANSFER CANCELLED", "USER CANCELLED"))
}
// Clear screen when transfer steps change content height significantly
// (e.g. confirm→authcode, fields→confirm, authcode→complete).
if m.transfer.step != prevStep {
cmd = tea.Batch(cmd, tea.ClearScreen)
} }
// Transfer completed — log it. // Transfer completed — log it.
@@ -234,7 +245,7 @@ func (m *model) updateTransfer(msg tea.Msg) (tea.Model, tea.Cmd) {
// Completed screen → any key goes back. // Completed screen → any key goes back.
if m.transfer.step == transferStepComplete { if m.transfer.step == transferStepComplete {
if _, ok := msg.(tea.KeyMsg); ok && prevStep == transferStepComplete { if _, ok := msg.(tea.KeyMsg); ok && prevStep == transferStepComplete {
m.goToMenu() return m, tea.Batch(cmd, m.goToMenu())
} }
} }
@@ -245,7 +256,7 @@ func (m *model) updateHistory(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd var cmd tea.Cmd
m.history, cmd = m.history.Update(msg) m.history, cmd = m.history.Update(msg)
if m.history.choice == "back" { if m.history.choice == "back" {
m.goToMenu() return m, tea.Batch(cmd, m.goToMenu())
} }
return m, cmd return m, cmd
} }
@@ -254,8 +265,7 @@ func (m *model) updateMessages(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd var cmd tea.Cmd
m.messages, cmd = m.messages.Update(msg) m.messages, cmd = m.messages.Update(msg)
if m.messages.choice == "back" { if m.messages.choice == "back" {
m.goToMenu() return m, tea.Batch(cmd, m.goToMenu())
return m, cmd
} }
// Log when viewing a message. // Log when viewing a message.
if m.messages.viewing >= 0 { if m.messages.viewing >= 0 {
@@ -274,8 +284,7 @@ func (m *model) updateChangePin(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
if m.pinStage == 3 { if m.pinStage == 3 {
m.goToMenu() return m, m.goToMenu()
return m, nil
} }
switch keyMsg.Type { switch keyMsg.Type {
@@ -302,8 +311,7 @@ func (m *model) updateChangePin(msg tea.Msg) (tea.Model, tea.Cmd) {
m.pinStage = 1 m.pinStage = 1
} }
case tea.KeyEscape: case tea.KeyEscape:
m.goToMenu() return m, m.goToMenu()
return m, nil
case tea.KeyBackspace: case tea.KeyBackspace:
if len(m.pinInput) > 0 { if len(m.pinInput) > 0 {
m.pinInput = m.pinInput[:len(m.pinInput)-1] m.pinInput = m.pinInput[:len(m.pinInput)-1]
@@ -367,8 +375,7 @@ func (m *model) updateAdmin(msg tea.Msg) (tea.Model, tea.Cmd) {
// Check for ESC before delegating. // Check for ESC before delegating.
if keyMsg, ok := msg.(tea.KeyMsg); ok && keyMsg.Type == tea.KeyEscape && !m.admin.locked { if keyMsg, ok := msg.(tea.KeyMsg); ok && keyMsg.Type == tea.KeyEscape && !m.admin.locked {
m.goToMenu() return m, m.goToMenu()
return m, nil
} }
var cmd tea.Cmd var cmd tea.Cmd
@@ -383,7 +390,7 @@ func (m *model) updateAdmin(msg tea.Msg) (tea.Model, tea.Cmd) {
// Log lockout. // Log lockout.
if m.admin.locked && !prevLocked { if m.admin.locked && !prevLocked {
cmd = tea.Batch(cmd, logAction(m.sess, cmd = tea.Batch(cmd, tea.ClearScreen, logAction(m.sess,
"ADMIN LOCKOUT", "ADMIN LOCKOUT",
"TERMINAL LOCKED - INCIDENT LOGGED")) "TERMINAL LOCKED - INCIDENT LOGGED"))
} }
@@ -391,14 +398,14 @@ func (m *model) updateAdmin(msg tea.Msg) (tea.Model, tea.Cmd) {
// If locked and any key pressed, go back. // If locked and any key pressed, go back.
if m.admin.locked { if m.admin.locked {
if _, ok := msg.(tea.KeyMsg); ok && prevLocked { if _, ok := msg.(tea.KeyMsg); ok && prevLocked {
m.goToMenu() return m, tea.Batch(cmd, m.goToMenu())
} }
} }
return m, cmd return m, cmd
} }
func (m *model) goToMenu() { func (m *model) goToMenu() tea.Cmd {
unread := 0 unread := 0
for _, msg := range m.state.Messages { for _, msg := range m.state.Messages {
if msg.Unread { if msg.Unread {
@@ -407,6 +414,7 @@ func (m *model) goToMenu() {
} }
m.screen = screenMenu m.screen = screenMenu
m.menu = newMenuModel(m.bankName, unread) m.menu = newMenuModel(m.bankName, unread)
return tea.ClearScreen
} }
// logAction returns a tea.Cmd that logs an action to the session store. // logAction returns a tea.Cmd that logs an action to the session store.

View File

@@ -165,8 +165,12 @@ func (m transferModel) View() string {
b.WriteString("\n\n") b.WriteString("\n\n")
b.WriteString(titleStyle.Render(" TRANSFER QUEUED FOR PROCESSING")) b.WriteString(titleStyle.Render(" TRANSFER QUEUED FOR PROCESSING"))
b.WriteString("\n\n") b.WriteString("\n\n")
routing := m.transfer.RoutingNumber
if len(routing) > 4 {
routing = routing[:4]
}
b.WriteString(baseStyle.Render(fmt.Sprintf(" CONFIRMATION #: WR-%s-%s", b.WriteString(baseStyle.Render(fmt.Sprintf(" CONFIRMATION #: WR-%s-%s",
m.transfer.RoutingNumber[:4], "847291"))) routing, "847291")))
b.WriteString("\n") b.WriteString("\n")
b.WriteString(baseStyle.Render(" STATUS: PENDING FEDWIRE SETTLEMENT")) b.WriteString(baseStyle.Render(" STATUS: PENDING FEDWIRE SETTLEMENT"))
b.WriteString("\n") b.WriteString("\n")

View File

@@ -100,11 +100,34 @@ func formatCurrency(cents int64) string {
return fmt.Sprintf("$%s.%02d", ds, remainder) return fmt.Sprintf("$%s.%02d", ds, remainder)
} }
// padLine pads a single line (which may contain ANSI codes) to termWidth
// using its visual width. Padding uses a black background so the terminal's
// default background doesn't bleed through.
func padLine(line string) string {
w := lipgloss.Width(line)
if w >= termWidth {
return line
}
return line + baseStyle.Render(strings.Repeat(" ", termWidth-w))
}
// padLines pads every line in a multi-line string to termWidth so that
// shorter lines fully overwrite previous content in the terminal.
func padLines(s string) string {
lines := strings.Split(s, "\n")
for i, line := range lines {
lines[i] = padLine(line)
}
return strings.Join(lines, "\n")
}
// screenFrame wraps content in the persistent header and footer. // screenFrame wraps content in the persistent header and footer.
func screenFrame(bankName, terminalID, region, content string) string { // The height parameter is used to pad the output to fill the terminal,
// preventing leftover lines from previous renders bleeding through.
func screenFrame(bankName, terminalID, region, content string, height int) string {
var b strings.Builder var b strings.Builder
// Header. // Header (4 lines).
b.WriteString(divider()) b.WriteString(divider())
b.WriteString("\n") b.WriteString("\n")
b.WriteString(centerText(bankName + " FEDERAL RESERVE SYSTEM")) b.WriteString(centerText(bankName + " FEDERAL RESERVE SYSTEM"))
@@ -117,12 +140,29 @@ func screenFrame(bankName, terminalID, region, content string) string {
// Content. // Content.
b.WriteString(content) b.WriteString(content)
// Footer. // Pad with blank lines between content and footer so the footer
// stays at the bottom and the total output fills the terminal height.
if height > 0 {
const headerLines = 4
const footerLines = 2
// strings.Count gives newlines; add 1 for the line after the last \n.
contentLines := strings.Count(content, "\n") + 1
used := headerLines + contentLines + footerLines
blankLine := baseStyle.Render(strings.Repeat(" ", termWidth))
for i := used; i < height; i++ {
b.WriteString(blankLine)
b.WriteString("\n")
}
}
// Footer (2 lines).
b.WriteString("\n") b.WriteString("\n")
b.WriteString(divider()) b.WriteString(divider())
b.WriteString("\n") b.WriteString("\n")
footer := fmt.Sprintf(" TERMINAL: %s | REGION: %s | ENCRYPTED SESSION ACTIVE", terminalID, region) footer := fmt.Sprintf(" TERMINAL: %s | REGION: %s | ENCRYPTED SESSION ACTIVE", terminalID, region)
b.WriteString(dimStyle.Render(padRight(footer, termWidth))) b.WriteString(dimStyle.Render(padRight(footer, termWidth)))
return b.String() // Pad every line to full terminal width so shorter lines overwrite
// leftover content from previous renders.
return padLines(b.String())
} }

View File

@@ -286,6 +286,21 @@ func (m *MemoryStore) GetRecentSessions(_ context.Context, limit int, activeOnly
return sessions, nil return sessions, nil
} }
func (m *MemoryStore) CloseActiveSessions(_ context.Context, disconnectedAt time.Time) (int64, error) {
m.mu.Lock()
defer m.mu.Unlock()
var count int64
t := disconnectedAt.UTC()
for _, s := range m.Sessions {
if s.DisconnectedAt == nil {
s.DisconnectedAt = &t
count++
}
}
return count, nil
}
func (m *MemoryStore) Close() error { func (m *MemoryStore) Close() error {
return nil return nil
} }

View File

@@ -351,6 +351,16 @@ func (s *SQLiteStore) GetRecentSessions(ctx context.Context, limit int, activeOn
return sessions, rows.Err() return sessions, rows.Err()
} }
func (s *SQLiteStore) CloseActiveSessions(ctx context.Context, disconnectedAt time.Time) (int64, error) {
res, err := s.db.ExecContext(ctx, `
UPDATE sessions SET disconnected_at = ? WHERE disconnected_at IS NULL`,
disconnectedAt.UTC().Format(time.RFC3339))
if err != nil {
return 0, fmt.Errorf("closing active sessions: %w", err)
}
return res.RowsAffected()
}
func (s *SQLiteStore) Close() error { func (s *SQLiteStore) Close() error {
return s.db.Close() return s.db.Close()
} }

View File

@@ -108,6 +108,11 @@ type Store interface {
// GetSessionEvents returns all events for a session ordered by id. // GetSessionEvents returns all events for a session ordered by id.
GetSessionEvents(ctx context.Context, sessionID string) ([]SessionEvent, error) GetSessionEvents(ctx context.Context, sessionID string) ([]SessionEvent, error)
// CloseActiveSessions sets disconnected_at for all sessions that are
// still marked as active. This should be called at startup to clean up
// sessions left over from a previous unclean shutdown.
CloseActiveSessions(ctx context.Context, disconnectedAt time.Time) (int64, error)
// Close releases any resources held by the store. // Close releases any resources held by the store.
Close() error Close() error
} }

View File

@@ -316,6 +316,51 @@ func TestSessionEvents(t *testing.T) {
}) })
} }
func TestCloseActiveSessions(t *testing.T) {
testStores(t, func(t *testing.T, newStore storeFactory) {
t.Run("no active sessions", func(t *testing.T) {
store := newStore(t)
ctx := context.Background()
n, err := store.CloseActiveSessions(ctx, time.Now())
if err != nil {
t.Fatalf("CloseActiveSessions: %v", err)
}
if n != 0 {
t.Errorf("closed %d, want 0", n)
}
})
t.Run("closes only active sessions", func(t *testing.T) {
store := newStore(t)
ctx := context.Background()
// Create 3 sessions: end one, leave two active.
id1, _ := store.CreateSession(ctx, "10.0.0.1", "root", "bash")
store.CreateSession(ctx, "10.0.0.2", "admin", "bash")
store.CreateSession(ctx, "10.0.0.3", "test", "bash")
store.EndSession(ctx, id1, time.Now())
n, err := store.CloseActiveSessions(ctx, time.Now())
if err != nil {
t.Fatalf("CloseActiveSessions: %v", err)
}
if n != 2 {
t.Errorf("closed %d, want 2", n)
}
// Verify no active sessions remain.
active, err := store.GetRecentSessions(ctx, 10, true)
if err != nil {
t.Fatalf("GetRecentSessions: %v", err)
}
if len(active) != 0 {
t.Errorf("active sessions = %d, want 0", len(active))
}
})
})
}
func TestGetRecentSessions(t *testing.T) { func TestGetRecentSessions(t *testing.T) {
testStores(t, func(t *testing.T, newStore storeFactory) { testStores(t, func(t *testing.T, newStore storeFactory) {
t.Run("empty", func(t *testing.T) { t.Run("empty", func(t *testing.T) {