MVP: Go daemon, password auth, persistent PTY terminals, React/xterm UI, PWA

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Mitchell-Johnson
2026-09-17 10:25:35 -07:00
co-authored by Claude Fable 5.1
parent 190604247f
commit 436732dac0
35 changed files with 3576 additions and 0 deletions
+287
View File
@@ -0,0 +1,287 @@
// Package term owns PTY-backed shell sessions that outlive any one browser tab.
package term
import (
"crypto/rand"
"encoding/hex"
"errors"
"os"
"os/exec"
"sort"
"sync"
"syscall"
"time"
"github.com/creack/pty"
)
const (
ringSize = 256 * 1024
clientBuf = 512
killGrace = 3 * time.Second
readChunk = 32 * 1024
defaultCol = 120
defaultRow = 30
)
var ErrNotFound = errors.New("terminal not found")
// Msg is one unit of output to a client: raw PTY bytes, or a control event.
type Msg struct {
Data []byte // non-nil for PTY output
Event string // "exit" when the shell has ended
Code int
}
type Client struct {
Ch chan Msg
session *Session
dropped bool
}
type Info struct {
ID string `json:"id"`
Name string `json:"name"`
Running bool `json:"running"`
ExitCode int `json:"exit_code"`
Created time.Time `json:"created"`
Clients int `json:"clients"`
}
type Session struct {
ID string
Name string
Created time.Time
mu sync.Mutex
ptmx *os.File
cmd *exec.Cmd
ring *Ring
clients map[*Client]struct{}
running bool
exitCode int
}
type Manager struct {
mu sync.RWMutex
sessions map[string]*Session
shell string
home string
OnChange func([]Info) // called (without locks held) when the list changes
}
func NewManager(shell, home string) *Manager {
return &Manager{sessions: map[string]*Session{}, shell: shell, home: home}
}
func newID() string {
b := make([]byte, 4)
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}
// Create starts a new login shell. id may be empty.
func (m *Manager) Create(id, name string) (*Session, error) {
if id == "" {
id = newID()
}
cmd := exec.Command(m.shell, "-l")
cmd.Dir = m.home
cmd.Env = append(os.Environ(), "TERM=xterm-256color", "COLORTERM=truecolor", "HAMLET=1")
ptmx, err := pty.StartWithSize(cmd, &pty.Winsize{Cols: defaultCol, Rows: defaultRow})
if err != nil {
return nil, err
}
s := &Session{
ID: id, Name: name, Created: time.Now(),
ptmx: ptmx, cmd: cmd, ring: NewRing(ringSize),
clients: map[*Client]struct{}{}, running: true,
}
m.mu.Lock()
m.sessions[id] = s
m.mu.Unlock()
go m.pump(s)
m.changed()
return s, nil
}
func (m *Manager) pump(s *Session) {
buf := make([]byte, readChunk)
for {
n, err := s.ptmx.Read(buf)
if n > 0 {
chunk := make([]byte, n)
copy(chunk, buf[:n])
s.mu.Lock()
s.ring.Write(chunk)
s.broadcast(Msg{Data: chunk})
s.mu.Unlock()
}
if err != nil {
break // EIO when the slave side closes
}
}
_ = s.cmd.Wait()
code := s.cmd.ProcessState.ExitCode()
s.mu.Lock()
s.running = false
s.exitCode = code
s.broadcast(Msg{Event: "exit", Code: code})
s.mu.Unlock()
_ = s.ptmx.Close()
m.changed()
}
// broadcast requires s.mu. Slow clients are dropped rather than blocking the PTY.
func (s *Session) broadcast(msg Msg) {
for c := range s.clients {
select {
case c.Ch <- msg:
default:
if !c.dropped {
c.dropped = true
close(c.Ch)
}
delete(s.clients, c)
}
}
}
func (m *Manager) get(id string) (*Session, error) {
m.mu.RLock()
defer m.mu.RUnlock()
s, ok := m.sessions[id]
if !ok {
return nil, ErrNotFound
}
return s, nil
}
func (m *Manager) List() []Info {
m.mu.RLock()
out := make([]Info, 0, len(m.sessions))
for _, s := range m.sessions {
s.mu.Lock()
out = append(out, Info{ID: s.ID, Name: s.Name, Running: s.running, ExitCode: s.exitCode,
Created: s.Created, Clients: len(s.clients)})
s.mu.Unlock()
}
m.mu.RUnlock()
sort.Slice(out, func(i, j int) bool { return out[i].Created.Before(out[j].Created) })
return out
}
func (m *Manager) Rename(id, name string) error {
s, err := m.get(id)
if err != nil {
return err
}
s.mu.Lock()
s.Name = name
s.mu.Unlock()
m.changed()
return nil
}
// Attach subscribes a client and returns the scrollback snapshot to replay first.
func (m *Manager) Attach(id string) (*Client, []byte, bool, error) {
s, err := m.get(id)
if err != nil {
return nil, nil, false, err
}
c := &Client{Ch: make(chan Msg, clientBuf), session: s}
s.mu.Lock()
snap := s.ring.Bytes()
running := s.running
s.clients[c] = struct{}{}
s.mu.Unlock()
m.changed()
return c, snap, running, nil
}
func (m *Manager) Detach(c *Client) {
s := c.session
s.mu.Lock()
if _, ok := s.clients[c]; ok {
delete(s.clients, c)
if !c.dropped {
c.dropped = true
close(c.Ch)
}
}
s.mu.Unlock()
m.changed()
}
func (m *Manager) Write(id string, p []byte) error {
s, err := m.get(id)
if err != nil {
return err
}
s.mu.Lock()
running := s.running
s.mu.Unlock()
if !running {
return nil
}
_, err = s.ptmx.Write(p)
return err
}
func (m *Manager) Resize(id string, cols, rows uint16) error {
s, err := m.get(id)
if err != nil {
return err
}
if cols == 0 || rows == 0 {
return nil
}
return pty.Setsize(s.ptmx, &pty.Winsize{Cols: cols, Rows: rows})
}
// Close ends the shell (SIGHUP to its process group, SIGKILL after a grace
// period) and forgets the session.
func (m *Manager) Close(id string) error {
m.mu.Lock()
s, ok := m.sessions[id]
if ok {
delete(m.sessions, id)
}
m.mu.Unlock()
if !ok {
return ErrNotFound
}
s.mu.Lock()
running := s.running
for c := range s.clients {
if !c.dropped {
c.dropped = true
close(c.Ch)
}
delete(s.clients, c)
}
s.mu.Unlock()
if running && s.cmd.Process != nil {
pid := s.cmd.Process.Pid
_ = syscall.Kill(-pid, syscall.SIGHUP)
go func() {
time.Sleep(killGrace)
_ = syscall.Kill(-pid, syscall.SIGKILL)
}()
}
m.changed()
return nil
}
func (m *Manager) changed() {
if m.OnChange != nil {
m.OnChange(m.List())
}
}
// Shutdown hangs up every shell. Called on daemon exit.
func (m *Manager) Shutdown() {
for _, s := range m.List() {
_ = m.Close(s.ID)
}
}