381 lines
8.7 KiB
Go
381 lines
8.7 KiB
Go
package luaplugin
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"errors"
|
|
"io"
|
|
"os"
|
|
"os/exec"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
lua "github.com/yuin/gopher-lua"
|
|
)
|
|
|
|
// Default binaries plugins may invoke via cliamp.exec.run(). Users can widen
|
|
// this via [plugins] allowed_binaries = "yt-dlp,ffmpeg,ffprobe" in config.toml.
|
|
var defaultAllowedBinaries = []string{"yt-dlp", "ffmpeg"}
|
|
|
|
// Per-process output cap (stdout+stderr). Once exceeded, further output is
|
|
// dropped and the process is still allowed to run to completion. Prevents a
|
|
// chatty subprocess from OOMing the player.
|
|
const execMaxOutputBytes = 4 << 20 // 4 MiB
|
|
|
|
// Per-plugin concurrency cap. Runaway plugins can't fork-bomb past this.
|
|
const execMaxPerPlugin = 4
|
|
|
|
// Hard timeout cap. Plugins may pass a smaller value; larger values clamp.
|
|
const execMaxTimeout = 30 * time.Minute
|
|
|
|
// execEntry tracks a single running subprocess.
|
|
type execEntry struct {
|
|
id int64
|
|
plugin *Plugin
|
|
cmd *exec.Cmd
|
|
cancel context.CancelFunc
|
|
done chan struct{}
|
|
}
|
|
|
|
// execManager owns all running plugin subprocesses.
|
|
type execManager struct {
|
|
mu sync.Mutex
|
|
entries map[int64]*execEntry
|
|
nextID atomic.Int64
|
|
perPlug map[*Plugin]int
|
|
allowed map[string]struct{}
|
|
allowMu sync.RWMutex
|
|
}
|
|
|
|
func newExecManager(allowed []string) *execManager {
|
|
em := &execManager{
|
|
entries: make(map[int64]*execEntry),
|
|
perPlug: make(map[*Plugin]int),
|
|
allowed: make(map[string]struct{}),
|
|
}
|
|
em.setAllowed(allowed)
|
|
return em
|
|
}
|
|
|
|
func (em *execManager) setAllowed(names []string) {
|
|
em.allowMu.Lock()
|
|
defer em.allowMu.Unlock()
|
|
em.allowed = make(map[string]struct{}, len(names))
|
|
for _, n := range names {
|
|
em.allowed[n] = struct{}{}
|
|
}
|
|
}
|
|
|
|
func (em *execManager) isAllowed(name string) bool {
|
|
em.allowMu.RLock()
|
|
defer em.allowMu.RUnlock()
|
|
_, ok := em.allowed[name]
|
|
return ok
|
|
}
|
|
|
|
func (em *execManager) add(e *execEntry) {
|
|
em.mu.Lock()
|
|
em.entries[e.id] = e
|
|
em.perPlug[e.plugin]++
|
|
em.mu.Unlock()
|
|
}
|
|
|
|
func (em *execManager) remove(e *execEntry) {
|
|
em.mu.Lock()
|
|
if _, ok := em.entries[e.id]; ok {
|
|
delete(em.entries, e.id)
|
|
em.perPlug[e.plugin]--
|
|
if em.perPlug[e.plugin] <= 0 {
|
|
delete(em.perPlug, e.plugin)
|
|
}
|
|
}
|
|
em.mu.Unlock()
|
|
}
|
|
|
|
// canStart returns true if the plugin is under its concurrency cap.
|
|
func (em *execManager) canStart(p *Plugin) bool {
|
|
em.mu.Lock()
|
|
defer em.mu.Unlock()
|
|
return em.perPlug[p] < execMaxPerPlugin
|
|
}
|
|
|
|
// stopPlugin cancels every process owned by the given plugin and blocks
|
|
// until each one has exited. Called from Manager.cleanupPlugin.
|
|
func (em *execManager) stopPlugin(p *Plugin) {
|
|
em.mu.Lock()
|
|
var victims []*execEntry
|
|
for _, e := range em.entries {
|
|
if e.plugin == p {
|
|
victims = append(victims, e)
|
|
}
|
|
}
|
|
em.mu.Unlock()
|
|
|
|
for _, e := range victims {
|
|
e.cancel()
|
|
}
|
|
for _, e := range victims {
|
|
<-e.done
|
|
}
|
|
}
|
|
|
|
// stopAll cancels every process. Called from Manager.Close.
|
|
func (em *execManager) stopAll() {
|
|
em.mu.Lock()
|
|
var all []*execEntry
|
|
for _, e := range em.entries {
|
|
all = append(all, e)
|
|
}
|
|
em.mu.Unlock()
|
|
|
|
for _, e := range all {
|
|
e.cancel()
|
|
}
|
|
for _, e := range all {
|
|
<-e.done
|
|
}
|
|
}
|
|
|
|
// registerExecAPI adds cliamp.exec.run(binary, args, opts?) -> handle, err.
|
|
// The exec API is only functional for plugins declaring permissions = {"exec"}.
|
|
// Without the permission, cliamp.exec is a no-op table that logs once.
|
|
func registerExecAPI(L *lua.LState, cliamp *lua.LTable, em *execManager, p *Plugin, logger *pluginLogger) {
|
|
tbl := L.NewTable()
|
|
|
|
warned := false
|
|
guard := func() bool {
|
|
if p.perms[PermExec] {
|
|
return true
|
|
}
|
|
if !warned {
|
|
logger.log(p.Name, "warn", "cliamp.exec requires permissions = {\"exec\"} — further warnings suppressed")
|
|
warned = true
|
|
}
|
|
return false
|
|
}
|
|
|
|
L.SetField(tbl, "run", L.NewFunction(func(L *lua.LState) int {
|
|
if !guard() {
|
|
L.Push(lua.LNil)
|
|
L.Push(lua.LString("exec permission required"))
|
|
return 2
|
|
}
|
|
|
|
binary := L.CheckString(1)
|
|
argsTbl := L.CheckTable(2)
|
|
optsTbl := L.OptTable(3, nil)
|
|
|
|
if !em.isAllowed(binary) {
|
|
L.Push(lua.LNil)
|
|
L.Push(lua.LString("binary not in allowlist: " + binary))
|
|
return 2
|
|
}
|
|
|
|
path, err := exec.LookPath(binary)
|
|
if err != nil {
|
|
L.Push(lua.LNil)
|
|
L.Push(lua.LString("binary not found on PATH: " + binary))
|
|
return 2
|
|
}
|
|
|
|
// Flatten argv. Every entry must be a string; reject non-strings rather
|
|
// than coercing, so plugins can't sneak nested tables into argv.
|
|
var argv []string
|
|
var argErr error
|
|
argsTbl.ForEach(func(_, v lua.LValue) {
|
|
if argErr != nil {
|
|
return
|
|
}
|
|
if v.Type() != lua.LTString {
|
|
argErr = errors.New("args must all be strings")
|
|
return
|
|
}
|
|
argv = append(argv, v.String())
|
|
})
|
|
if argErr != nil {
|
|
L.Push(lua.LNil)
|
|
L.Push(lua.LString(argErr.Error()))
|
|
return 2
|
|
}
|
|
|
|
var onStdout, onStderr, onExit *lua.LFunction
|
|
cwd := ""
|
|
timeout := execMaxTimeout
|
|
|
|
if optsTbl != nil {
|
|
if fn, ok := optsTbl.RawGetString("on_stdout").(*lua.LFunction); ok {
|
|
onStdout = fn
|
|
}
|
|
if fn, ok := optsTbl.RawGetString("on_stderr").(*lua.LFunction); ok {
|
|
onStderr = fn
|
|
}
|
|
if fn, ok := optsTbl.RawGetString("on_exit").(*lua.LFunction); ok {
|
|
onExit = fn
|
|
}
|
|
if s, ok := optsTbl.RawGetString("cwd").(lua.LString); ok {
|
|
cwd = string(s)
|
|
}
|
|
if n, ok := optsTbl.RawGetString("timeout").(lua.LNumber); ok && float64(n) > 0 {
|
|
t := time.Duration(float64(n) * float64(time.Second))
|
|
if t < timeout {
|
|
timeout = t
|
|
}
|
|
}
|
|
}
|
|
|
|
if cwd != "" && !isWriteAllowed(cwd) {
|
|
L.Push(lua.LNil)
|
|
L.Push(lua.LString("cwd not in write allowlist"))
|
|
return 2
|
|
}
|
|
|
|
if !em.canStart(p) {
|
|
L.Push(lua.LNil)
|
|
L.Push(lua.LString("per-plugin exec concurrency cap reached"))
|
|
return 2
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
|
cmd := exec.CommandContext(ctx, path, argv...)
|
|
if cwd != "" {
|
|
cmd.Dir = cwd
|
|
}
|
|
// Empty env by default — plugins should not inherit secrets like
|
|
// AWS_*, SSH_*, etc. yt-dlp and ffmpeg both run fine with a minimal env.
|
|
cmd.Env = minimalExecEnv()
|
|
|
|
stdout, err := cmd.StdoutPipe()
|
|
if err != nil {
|
|
cancel()
|
|
L.Push(lua.LNil)
|
|
L.Push(lua.LString(err.Error()))
|
|
return 2
|
|
}
|
|
stderr, err := cmd.StderrPipe()
|
|
if err != nil {
|
|
cancel()
|
|
L.Push(lua.LNil)
|
|
L.Push(lua.LString(err.Error()))
|
|
return 2
|
|
}
|
|
if err := cmd.Start(); err != nil {
|
|
cancel()
|
|
L.Push(lua.LNil)
|
|
L.Push(lua.LString(err.Error()))
|
|
return 2
|
|
}
|
|
|
|
id := em.nextID.Add(1)
|
|
entry := &execEntry{
|
|
id: id,
|
|
plugin: p,
|
|
cmd: cmd,
|
|
cancel: cancel,
|
|
done: make(chan struct{}),
|
|
}
|
|
em.add(entry)
|
|
|
|
// Shared output budget across stdout+stderr.
|
|
var outUsed atomic.Int64
|
|
|
|
pipeStream := func(r io.Reader, fn *lua.LFunction) {
|
|
scanner := bufio.NewScanner(r)
|
|
// Allow longer lines than default 64KiB for noisy tools like ffmpeg.
|
|
scanner.Buffer(make([]byte, 64*1024), 1<<20)
|
|
for scanner.Scan() {
|
|
line := scanner.Text()
|
|
if outUsed.Add(int64(len(line)+1)) > execMaxOutputBytes {
|
|
// Budget exhausted — drain silently.
|
|
for scanner.Scan() {
|
|
}
|
|
return
|
|
}
|
|
if fn == nil {
|
|
continue
|
|
}
|
|
p.mu.Lock()
|
|
_ = p.L.CallByParam(lua.P{
|
|
Fn: fn,
|
|
NRet: 0,
|
|
Protect: true,
|
|
}, lua.LString(line))
|
|
p.mu.Unlock()
|
|
}
|
|
}
|
|
|
|
var wg sync.WaitGroup
|
|
wg.Add(2)
|
|
go func() { defer wg.Done(); pipeStream(stdout, onStdout) }()
|
|
go func() { defer wg.Done(); pipeStream(stderr, onStderr) }()
|
|
|
|
go func() {
|
|
wg.Wait()
|
|
waitErr := cmd.Wait()
|
|
ctxErr := ctx.Err()
|
|
cancel()
|
|
em.remove(entry)
|
|
|
|
code := 0
|
|
if waitErr != nil {
|
|
if ctxErr != nil {
|
|
code = -1 // cancelled or timed out
|
|
} else {
|
|
var exitErr *exec.ExitError
|
|
if errors.As(waitErr, &exitErr) {
|
|
code = exitErr.ExitCode()
|
|
} else {
|
|
code = -2 // other error
|
|
}
|
|
}
|
|
}
|
|
|
|
if onExit != nil {
|
|
p.mu.Lock()
|
|
_ = p.L.CallByParam(lua.P{
|
|
Fn: onExit,
|
|
NRet: 0,
|
|
Protect: true,
|
|
}, lua.LNumber(code))
|
|
p.mu.Unlock()
|
|
}
|
|
close(entry.done)
|
|
}()
|
|
|
|
// Build a Lua handle with :cancel() and :alive().
|
|
handle := L.NewTable()
|
|
L.SetField(handle, "cancel", L.NewFunction(func(L *lua.LState) int {
|
|
entry.cancel()
|
|
return 0
|
|
}))
|
|
L.SetField(handle, "alive", L.NewFunction(func(L *lua.LState) int {
|
|
select {
|
|
case <-entry.done:
|
|
L.Push(lua.LFalse)
|
|
default:
|
|
L.Push(lua.LTrue)
|
|
}
|
|
return 1
|
|
}))
|
|
L.SetField(handle, "id", lua.LNumber(id))
|
|
|
|
L.Push(handle)
|
|
return 1
|
|
}))
|
|
|
|
L.SetField(cliamp, "exec", tbl)
|
|
}
|
|
|
|
// homeEnv returns the user's home directory for subprocess HOME, preferring
|
|
// $HOME, then os.UserHomeDir(), falling back to os.TempDir() when unset.
|
|
// yt-dlp and ffmpeg both read HOME (~/.cache, ~/.config).
|
|
func homeEnv() string {
|
|
if home, ok := os.LookupEnv("HOME"); ok && home != "" {
|
|
return home
|
|
}
|
|
if h, err := os.UserHomeDir(); err == nil {
|
|
return h
|
|
}
|
|
return os.TempDir()
|
|
}
|