Files
lpra/internal/agent/terminal.go
T

149 lines
4.3 KiB
Go

package agent
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"syscall"
"time"
"github.com/creack/pty"
)
// PTY is a pseudo-terminal running an interactive login shell.
type PTY struct {
cmd *exec.Cmd
f *os.File
exited chan struct{} // closed once the shell has exited
once sync.Once
}
// hangupGrace is how long a shell gets after the hangup to save its history
// and exit before it is killed.
const hangupGrace = 3 * time.Second
// openPTYs are the terminals running now, to close on shutdown.
var openPTYs sync.Map // *PTY -> struct{}
// CloseTerminals hangs up every open terminal and waits for them, so the
// shells save their history before the agent exits.
func CloseTerminals() {
var wg sync.WaitGroup
openPTYs.Range(func(k, _ any) bool {
wg.Add(1)
go func() { defer wg.Done(); k.(*PTY).Close() }()
return true
})
wg.Wait()
}
// StartPTY launches a login shell attached to a new PTY. With user empty (or
// equal to the agent's own user) it is the agent's shell - root by default,
// since the agent runs as a root systemd service. Any other user is entered via
// `su -l`, which only a root agent can do without a password. dir, when it
// is a folder, is where the shell starts instead of home.
func StartPTY(user, dir string) (*PTY, error) {
if dir != "" && (!filepath.IsAbs(dir) || strings.ContainsAny(dir, "\x00\r\n") || !isDir(dir)) {
dir = ""
}
var cmd *exec.Cmd
env := append(os.Environ(), "TERM=xterm-256color")
if user == "" || user == currentUser() {
cmd = exec.Command(defaultShell(), "-l")
// A login shell does not change directory by itself: it would start
// where the service runs, "/". Start it at home, and say where home
// is, as a root service often has no $HOME.
if home := homeDir(""); isDir(home) {
cmd.Dir = home
env = append(env, "HOME="+home)
}
if dir != "" {
cmd.Dir = dir
}
} else {
if !validUserName(user) {
return nil, fmt.Errorf("недопустимое имя пользователя %q", user)
}
if os.Geteuid() != 0 {
return nil, fmt.Errorf("агент запущен от %s и не может открыть сессию от %s", currentUser(), user)
}
if !userExists(user) {
return nil, fmt.Errorf("пользователь %s не найден", user)
}
su, err := exec.LookPath("su")
if err != nil {
return nil, fmt.Errorf("su не найден: %w", err)
}
cmd = exec.Command(su, "-l", user)
// su -l always goes home; to start elsewhere, its shell changes
// directory first and becomes the login shell (sh and csh alike).
if dir != "" {
cmd = exec.Command(su, "-l", user, "-c", "cd "+shQuote(dir)+"; exec \"$SHELL\" -l")
}
// su -l goes to the user's home; starting there too keeps the shell
// out of "/" should it not.
if home := homeDir(user); isDir(home) {
cmd.Dir = home
}
}
cmd.Env = env
f, err := pty.Start(cmd)
if err != nil {
return nil, err
}
p := &PTY{cmd: cmd, f: f, exited: make(chan struct{})}
go func() { _ = cmd.Wait(); close(p.exited) }()
openPTYs.Store(p, struct{}{})
return p, nil
}
func isDir(p string) bool {
info, err := os.Stat(p)
return err == nil && info.IsDir()
}
// Read reads terminal output.
func (p *PTY) Read(b []byte) (int, error) { return p.f.Read(b) }
// Write writes keystrokes to the terminal.
func (p *PTY) Write(b []byte) (int, error) { return p.f.Write(b) }
// Resize changes the terminal window size.
func (p *PTY) Resize(cols, rows uint16) {
if cols == 0 || rows == 0 {
return
}
_ = pty.Setsize(p.f, &pty.Winsize{Rows: rows, Cols: cols})
}
// Close hangs the terminal up as a closed window would: the shell gets
// SIGHUP, saves its history and exits. One that does not within hangupGrace
// is killed. Safe to call more than once.
func (p *PTY) Close() error {
var err error
p.once.Do(func() {
// Closing the master hangs up the session; the signal to the
// shell's process group makes sure it hears, through su as well.
err = p.f.Close()
pid := p.cmd.Process.Pid
_ = syscall.Kill(-pid, syscall.SIGHUP)
select {
case <-p.exited:
case <-time.After(hangupGrace):
_ = syscall.Kill(-pid, syscall.SIGKILL)
<-p.exited
}
openPTYs.Delete(p)
})
return err
}
// shQuote puts s in single quotes for sh and csh alike, closing and
// reopening them around each quote inside.
func shQuote(s string) string {
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
}