129 lines
3.7 KiB
Go
129 lines
3.7 KiB
Go
package agent
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"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.
|
|
func StartPTY(user string) (*PTY, error) {
|
|
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)
|
|
}
|
|
} 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 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
|
|
}
|