Compare commits
3 Commits
d78d461236
...
5d0c8cc20c
| Author | SHA1 | Date | |
|---|---|---|---|
|
5d0c8cc20c
|
|||
|
d226c32b9b
|
|||
|
86786c9d05
|
@@ -65,6 +65,13 @@ func run() error {
|
||||
}
|
||||
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)
|
||||
defer cancel()
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"time"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
|
||||
"git.t-juice.club/torjus/oubliette/internal/shell"
|
||||
"git.t-juice.club/torjus/oubliette/internal/storage"
|
||||
@@ -520,7 +521,7 @@ func TestConfigString(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") {
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ type model struct {
|
||||
state *bankState
|
||||
screen screen
|
||||
quitting bool
|
||||
height int
|
||||
|
||||
login loginModel
|
||||
menu menuModel
|
||||
@@ -76,8 +77,12 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m, tea.Quit
|
||||
}
|
||||
|
||||
if keyMsg, ok := msg.(tea.KeyMsg); ok {
|
||||
if keyMsg.Type == tea.KeyCtrlC {
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
m.height = msg.Height
|
||||
return m, nil
|
||||
case tea.KeyMsg:
|
||||
if msg.Type == tea.KeyCtrlC {
|
||||
m.quitting = true
|
||||
return m, tea.Quit
|
||||
}
|
||||
@@ -130,7 +135,7 @@ func (m *model) View() string {
|
||||
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 ---
|
||||
@@ -142,8 +147,8 @@ func (m *model) updateLogin(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if m.login.stage == 2 {
|
||||
// Login always succeeds — this is a honeypot.
|
||||
logCmd := logAction(m.sess, fmt.Sprintf("LOGIN acct=%s", m.login.accountNum), "ACCESS GRANTED")
|
||||
m.goToMenu()
|
||||
return m, tea.Batch(cmd, logCmd)
|
||||
clearCmd := m.goToMenu()
|
||||
return m, tea.Batch(cmd, logCmd, clearCmd)
|
||||
}
|
||||
return m, cmd
|
||||
}
|
||||
@@ -158,36 +163,36 @@ func (m *model) updateMenu(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
case "1":
|
||||
m.screen = screenAccountSummary
|
||||
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":
|
||||
m.screen = screenAccountDetail
|
||||
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":
|
||||
m.screen = screenTransfer
|
||||
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":
|
||||
m.screen = screenHistory
|
||||
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":
|
||||
m.screen = screenMessages
|
||||
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":
|
||||
m.screen = screenChangePin
|
||||
m.pinInput = ""
|
||||
m.pinStage = 0
|
||||
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":
|
||||
m.quitting = true
|
||||
return m, tea.Batch(logAction(m.sess, "LOGOUT", "SESSION ENDED"), tea.Quit)
|
||||
case "99", "admin", "ADMIN":
|
||||
m.screen = screenAdmin
|
||||
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.
|
||||
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) {
|
||||
if _, ok := msg.(tea.KeyMsg); ok {
|
||||
m.goToMenu()
|
||||
return m, m.goToMenu()
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
@@ -206,7 +211,7 @@ func (m *model) updateAccountDetail(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
var cmd tea.Cmd
|
||||
m.detail, cmd = m.detail.Update(msg)
|
||||
if m.detail.choice == "back" {
|
||||
m.goToMenu()
|
||||
return m, tea.Batch(cmd, m.goToMenu())
|
||||
}
|
||||
return m, cmd
|
||||
}
|
||||
@@ -218,8 +223,14 @@ func (m *model) updateTransfer(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
|
||||
// Transfer cancelled.
|
||||
if m.transfer.confirm == "cancelled" {
|
||||
m.goToMenu()
|
||||
return m, logAction(m.sess, "WIRE TRANSFER CANCELLED", "USER CANCELLED")
|
||||
clearCmd := m.goToMenu()
|
||||
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.
|
||||
@@ -234,7 +245,7 @@ func (m *model) updateTransfer(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
// Completed screen → any key goes back.
|
||||
if m.transfer.step == 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
|
||||
m.history, cmd = m.history.Update(msg)
|
||||
if m.history.choice == "back" {
|
||||
m.goToMenu()
|
||||
return m, tea.Batch(cmd, m.goToMenu())
|
||||
}
|
||||
return m, cmd
|
||||
}
|
||||
@@ -254,8 +265,7 @@ func (m *model) updateMessages(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
var cmd tea.Cmd
|
||||
m.messages, cmd = m.messages.Update(msg)
|
||||
if m.messages.choice == "back" {
|
||||
m.goToMenu()
|
||||
return m, cmd
|
||||
return m, tea.Batch(cmd, m.goToMenu())
|
||||
}
|
||||
// Log when viewing a message.
|
||||
if m.messages.viewing >= 0 {
|
||||
@@ -274,8 +284,7 @@ func (m *model) updateChangePin(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
|
||||
if m.pinStage == 3 {
|
||||
m.goToMenu()
|
||||
return m, nil
|
||||
return m, m.goToMenu()
|
||||
}
|
||||
|
||||
switch keyMsg.Type {
|
||||
@@ -302,8 +311,7 @@ func (m *model) updateChangePin(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.pinStage = 1
|
||||
}
|
||||
case tea.KeyEscape:
|
||||
m.goToMenu()
|
||||
return m, nil
|
||||
return m, m.goToMenu()
|
||||
case tea.KeyBackspace:
|
||||
if len(m.pinInput) > 0 {
|
||||
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.
|
||||
if keyMsg, ok := msg.(tea.KeyMsg); ok && keyMsg.Type == tea.KeyEscape && !m.admin.locked {
|
||||
m.goToMenu()
|
||||
return m, nil
|
||||
return m, m.goToMenu()
|
||||
}
|
||||
|
||||
var cmd tea.Cmd
|
||||
@@ -383,7 +390,7 @@ func (m *model) updateAdmin(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
|
||||
// Log lockout.
|
||||
if m.admin.locked && !prevLocked {
|
||||
cmd = tea.Batch(cmd, logAction(m.sess,
|
||||
cmd = tea.Batch(cmd, tea.ClearScreen, logAction(m.sess,
|
||||
"ADMIN LOCKOUT",
|
||||
"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 m.admin.locked {
|
||||
if _, ok := msg.(tea.KeyMsg); ok && prevLocked {
|
||||
m.goToMenu()
|
||||
return m, tea.Batch(cmd, m.goToMenu())
|
||||
}
|
||||
}
|
||||
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
func (m *model) goToMenu() {
|
||||
func (m *model) goToMenu() tea.Cmd {
|
||||
unread := 0
|
||||
for _, msg := range m.state.Messages {
|
||||
if msg.Unread {
|
||||
@@ -407,6 +414,7 @@ func (m *model) goToMenu() {
|
||||
}
|
||||
m.screen = screenMenu
|
||||
m.menu = newMenuModel(m.bankName, unread)
|
||||
return tea.ClearScreen
|
||||
}
|
||||
|
||||
// logAction returns a tea.Cmd that logs an action to the session store.
|
||||
|
||||
@@ -165,8 +165,12 @@ func (m transferModel) View() string {
|
||||
b.WriteString("\n\n")
|
||||
b.WriteString(titleStyle.Render(" TRANSFER QUEUED FOR PROCESSING"))
|
||||
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",
|
||||
m.transfer.RoutingNumber[:4], "847291")))
|
||||
routing, "847291")))
|
||||
b.WriteString("\n")
|
||||
b.WriteString(baseStyle.Render(" STATUS: PENDING FEDWIRE SETTLEMENT"))
|
||||
b.WriteString("\n")
|
||||
|
||||
@@ -100,11 +100,34 @@ func formatCurrency(cents int64) string {
|
||||
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.
|
||||
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
|
||||
|
||||
// Header.
|
||||
// Header (4 lines).
|
||||
b.WriteString(divider())
|
||||
b.WriteString("\n")
|
||||
b.WriteString(centerText(bankName + " FEDERAL RESERVE SYSTEM"))
|
||||
@@ -117,12 +140,29 @@ func screenFrame(bankName, terminalID, region, content string) string {
|
||||
// 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(divider())
|
||||
b.WriteString("\n")
|
||||
footer := fmt.Sprintf(" TERMINAL: %s | REGION: %s | ENCRYPTED SESSION ACTIVE", terminalID, region)
|
||||
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())
|
||||
}
|
||||
|
||||
@@ -286,6 +286,21 @@ func (m *MemoryStore) GetRecentSessions(_ context.Context, limit int, activeOnly
|
||||
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 {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -351,6 +351,16 @@ func (s *SQLiteStore) GetRecentSessions(ctx context.Context, limit int, activeOn
|
||||
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 {
|
||||
return s.db.Close()
|
||||
}
|
||||
|
||||
@@ -108,6 +108,11 @@ type Store interface {
|
||||
// GetSessionEvents returns all events for a session ordered by id.
|
||||
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() error
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
testStores(t, func(t *testing.T, newStore storeFactory) {
|
||||
t.Run("empty", func(t *testing.T) {
|
||||
|
||||
Reference in New Issue
Block a user