Files
hamlab/internal/term/manager.go
T
Mitchell-JohnsonandClaude Fable 5.1 488d929733 Portable install: the binary installs itself; static builds on five architectures; root via sudo, doas or uid 0; every package manager; older systemd; no lsblk
- hamlab install / uninstall (internal/setup): systemd or OpenRC, elevates through
  sudo or doas, runs as the invoking user, sets or generates the password, names only
  journal groups that exist, waits for /healthz, reports an active firewalld/ufw.
  --purge and the data chown never touch a directory that isn't Ham Lab's alone.
- install.sh: curl-able bootstrap (arch detection, SHA256SUMS, offline beside the
  binaries, noexec /tmp fallback). make release builds dist/ for amd64, arm64, arm
  (ARMv6), 386, riscv64. Every build is CGO_ENABLED=0; the native one used to link glibc.
- internal/priv replaces every literal `sudo -n`: uid 0, sudo or doas.
- Updates acts on dnf, zypper, pacman and apk as well as apt; dnf shows installed versions.
- Services falls back to the plain systemctl table before systemd 246 and says so on
  hosts without systemd. Disks falls back to /sys/block without lsblk.
- Listener binds with IP_FREEBIND, so a LAN address that isn't up at boot is no failure.
- Self-update: the new build must pass `check` against the live config, is put in
  place by atomic rename, and the daemon re-execs itself (same under any supervisor).
- State and config writes are fsynced; a corrupt state.json is set aside, not fatal.
- Terminal shutdown no longer empties the saved list, so names come back after a
  restart by design rather than by winning a race.
- Config default is ~/.config/hamlab; an existing ~/apps/hamlab is still found.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 23:00:20 -07:00

301 lines
6.1 KiB
Go

// 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"
"sync/atomic"
"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
closing atomic.Bool
}
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", "HAMLAB=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()
// An exited shell stays listed for a while so the exit is visible, then reaps itself.
time.AfterFunc(10*time.Minute, func() {
m.mu.Lock()
cur, ok := m.sessions[s.ID]
m.mu.Unlock()
if ok && cur == s && !s.running {
_ = m.Close(s.ID)
}
})
}
// 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.closing.Load() {
m.OnChange(m.List())
}
}
// Shutdown hangs up every shell. Called on daemon exit. The saved list is left as it
// was, because that list is what the next start re-opens.
func (m *Manager) Shutdown() {
m.closing.Store(true)
for _, s := range m.List() {
_ = m.Close(s.ID)
}
}