This repository has been archived on 2026-03-09. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
oubliette/internal/shell/bash/bash.go
Torjus Håkestad d4380c0aea chore: add golangci-lint config and fix all lint issues
Enable 15 additional linters (gosec, errorlint, gocritic, modernize,
misspell, bodyclose, sqlclosecheck, nilerr, unconvert, durationcheck,
sloglint, wastedassign, usestdlibvars) with sensible exclusion rules.

Fix all findings: errors.Is for error comparisons, run() pattern in
main to avoid exitAfterDefer, ReadHeaderTimeout for Slowloris
protection, bounds check in escape sequence reader, WaitGroup.Go,
slices.Contains, range-over-int loops, and http.MethodGet constants.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 21:43:49 +01:00

161 lines
3.4 KiB
Go

package bash
import (
"context"
"errors"
"fmt"
"io"
"strings"
"time"
"git.t-juice.club/torjus/oubliette/internal/shell"
)
const sessionTimeout = 5 * time.Minute
// BashShell emulates a basic bash-like shell.
type BashShell struct{}
// NewBashShell returns a new BashShell instance.
func NewBashShell() *BashShell {
return &BashShell{}
}
func (b *BashShell) Name() string { return "bash" }
func (b *BashShell) Description() string { return "Basic bash-like shell emulator" }
func (b *BashShell) Handle(ctx context.Context, sess *shell.SessionContext, rw io.ReadWriteCloser) error {
ctx, cancel := context.WithTimeout(ctx, sessionTimeout)
defer cancel()
username := sess.Username
if sess.CommonConfig.FakeUser != "" {
username = sess.CommonConfig.FakeUser
}
hostname := sess.CommonConfig.Hostname
fs := newFilesystem(hostname)
state := &shellState{
cwd: "/root",
username: username,
hostname: hostname,
fs: fs,
}
// Send banner.
if sess.CommonConfig.Banner != "" {
fmt.Fprint(rw, sess.CommonConfig.Banner)
}
fmt.Fprintf(rw, "Last login: %s from 10.0.0.1\r\n",
time.Now().Add(-2*time.Hour).Format("Mon Jan 2 15:04:05 2006"))
for {
prompt := formatPrompt(state)
if _, err := fmt.Fprint(rw, prompt); err != nil {
return nil
}
line, err := readLine(ctx, rw)
if errors.Is(err, io.EOF) {
fmt.Fprint(rw, "logout\r\n")
return nil
}
if err != nil {
return nil
}
trimmed := strings.TrimSpace(line)
if trimmed == "" {
continue
}
result := dispatch(state, trimmed)
var output string
if result.output != "" {
output = result.output
// Convert newlines to \r\n for terminal display.
output = strings.ReplaceAll(output, "\r\n", "\n")
output = strings.ReplaceAll(output, "\n", "\r\n")
fmt.Fprintf(rw, "%s\r\n", output)
}
// Log command and output to store.
if sess.Store != nil {
if err := sess.Store.AppendSessionLog(ctx, sess.SessionID, trimmed, output); err != nil {
return fmt.Errorf("append session log: %w", err)
}
}
if result.exit {
return nil
}
}
}
func formatPrompt(state *shellState) string {
cwd := state.cwd
if cwd == "/root" {
cwd = "~"
} else if strings.HasPrefix(cwd, "/root/") {
cwd = "~" + cwd[5:]
}
return fmt.Sprintf("%s@%s:%s# ", state.username, state.hostname, cwd)
}
// readLine reads a line of input byte-by-byte, handling backspace, Ctrl+C, and Ctrl+D.
func readLine(ctx context.Context, rw io.ReadWriter) (string, error) {
var buf []byte
b := make([]byte, 1)
for {
select {
case <-ctx.Done():
return "", ctx.Err()
default:
}
n, err := rw.Read(b)
if err != nil {
return "", err
}
if n == 0 {
continue
}
ch := b[0]
switch {
case ch == '\r' || ch == '\n':
fmt.Fprint(rw, "\r\n")
return string(buf), nil
case ch == 4: // Ctrl+D
if len(buf) == 0 {
return "", io.EOF
}
case ch == 3: // Ctrl+C
fmt.Fprint(rw, "^C\r\n")
return "", nil
case ch == 127 || ch == 8: // DEL or Backspace
if len(buf) > 0 {
buf = buf[:len(buf)-1]
fmt.Fprint(rw, "\b \b")
}
case ch == 27: // ESC - start of escape sequence
// Read and discard the rest of the escape sequence.
// Most are 3 bytes: ESC [ X (arrow keys, etc.)
next := make([]byte, 1)
if n, _ := rw.Read(next); n > 0 && next[0] == '[' {
rw.Read(next) // read the final byte
}
case ch >= 32 && ch < 127: // printable ASCII
buf = append(buf, ch)
rw.Write([]byte{ch})
}
}
}