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:
co-authored by
Claude Fable 5.1
parent
190604247f
commit
436732dac0
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user