Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 60 additions & 2 deletions docs/CLI-ADAPTER.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,63 @@ re-parsed as shell metacharacters. Passthrough is strictly more powerful than an
enumerated surface — the caller chooses the subcommand and flags — so reserve it
for trusted callers and keep CLI apps `guarded` (below).

A passthrough route whose base command is a dispatcher (a script that runs
`$dir/$1`) can be steered to any host binary by a relative `args[0]`
(`"../../../bin/sh"`). List the tools it may run with `tools:` and every call's
`args[0]` must be exactly one of those bare names:

```yaml
cli:
passthrough: true
tools: [redis-server, redis-cli, redis-benchmark]
```

## Servers the app owns — `cli.service`

A method that starts a long-running server (a local database, a dev server)
must not fork it into the background: a daemonized server calls `setsid()`,
leaves the adapter's process group and is reparented to init, so it survives
the app's stop, the supervisor's stop and a daemon crash, and nothing ever
stops it. Declare such a method as a service:

```yaml
- name: redis.start
cli:
args: [redis-server, --port, "${port}", --bind, 127.0.0.1, --dir, "${dir}"]
service:
ready_tcp: "127.0.0.1:${port}" # the call returns once this accepts
ready_timeout: 30s # not ready by then: stopped, call fails
log_file: "${dir}/redis-${port}.log" # its tail is returned on failure
force_args: [--daemonize, "no"] # appended LAST, so it always wins
```

The server runs as the adapter's own foreground child, in its process group,
and stays up after the call returns. A clean stop of the app stops it with
SIGTERM (SIGKILL after 8 s); if the app is SIGKILLed, the child guard takes it
down; on Linux it also gets SIGTERM from the kernel when the adapter dies. A
`ready_tcp` address that is already accepting fails the call before anything
starts, and a process that exits before it is ready (`--version`, a config
error) returns its output like a plain command.

On a passthrough route, `service.tools` names the tools that start a server
(needs `cli.tools`). There are no `${field}` params, so readiness comes from
the argv: with `ready_port_flag`, a call that passes that flag (`--port N` or
`--port=N`) is ready once `127.0.0.1:N` accepts (and fails fast if something
already does); otherwise it is ready after staying up for `ready_after`
(default 1 s). Prefer the port: a freshly staged binary's first launch can
take seconds before it listens (macOS assesses it on first run; Rosetta
translates an amd64 binary), well past `ready_after`.

```yaml
cli:
passthrough: true
tools: [redis-server, redis-cli, redis-sentinel]
service:
tools: [redis-server, redis-sentinel]
ready_port_flag: --port
force_args: [--daemonize, "no"]
```

## Hardening built into the runner

The generated `exec.go` is defensive by default:
Expand All @@ -59,8 +116,9 @@ The generated `exec.go` is defensive by default:
taken down by the child guard (`internal/backend/childguard.go`, a copy of
the adapter that exists only while a child is in flight). Children stay in
the adapter's process group, so a supervisor group stop reaches them too. A
server a CLI deliberately daemonizes into its own session is outside all of
this.
server a CLI daemonizes into its own session (`redis-server --daemonize yes`,
`pg_ctl start`, `mysqld --daemonize`) is outside all of this: declare the
method as a `cli.service` instead (below).
- **Staging leaves nothing behind** — native-binary downloads go to
`$APP/.staged/tmp`, not the shared `TMPDIR`; a stop during the first-spawn
download removes the partial file, and a SIGKILLed start's leftover is swept
Expand Down
28 changes: 28 additions & 0 deletions internal/publish/submission.go
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,22 @@ type SubCLIRoute struct {
Args []string `json:"args"`
ParamsAsFlags bool `json:"params_as_flags"`
Passthrough bool `json:"passthrough"`
// Tools allowlists a passthrough call's args[0] (see scaffold.CLIRoute.Tools).
Tools []string `json:"tools,omitempty"`
// Service marks a method that starts a long-running server the adapter
// owns and stops with it (see scaffold.CLIService).
Service *SubCLIService `json:"service,omitempty"`
}

// SubCLIService mirrors scaffold.CLIService.
type SubCLIService struct {
ReadyTCP string `json:"ready_tcp,omitempty"`
ReadyAfter string `json:"ready_after,omitempty"`
ReadyTimeout string `json:"ready_timeout,omitempty"`
LogFile string `json:"log_file,omitempty"`
ForceArgs []string `json:"force_args,omitempty"`
Tools []string `json:"tools,omitempty"`
ReadyPortFlag string `json:"ready_port_flag,omitempty"`
}

// SubParam is one structured input parameter (vs the old free-text field).
Expand Down Expand Up @@ -715,6 +731,18 @@ func (s Submission) ToConfig() *scaffold.Config {
Args: m.CLI.Args,
ParamsAsFlags: m.CLI.ParamsAsFlags,
Passthrough: m.CLI.Passthrough,
Tools: m.CLI.Tools,
}
if sv := m.CLI.Service; sv != nil {
method.CLI.Service = &scaffold.CLIService{
ReadyTCP: sv.ReadyTCP,
ReadyAfter: sv.ReadyAfter,
ReadyTimeout: sv.ReadyTimeout,
LogFile: sv.LogFile,
ForceArgs: sv.ForceArgs,
Tools: sv.Tools,
ReadyPortFlag: sv.ReadyPortFlag,
}
}
default:
route := &scaffold.HTTPRoute{Verb: orDefault(m.HTTP.Verb, "GET"), Path: m.HTTP.Path, CaptureTo: m.HTTP.CaptureTo}
Expand Down
137 changes: 137 additions & 0 deletions internal/scaffold/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -905,6 +905,65 @@ type CLIRoute struct {
Args []string `yaml:"args"`
ParamsAsFlags bool `yaml:"params_as_flags"`
Passthrough bool `yaml:"passthrough"`
// Tools (passthrough only) allowlists the first argv element: a call's
// args[0] must be exactly one of these bare names. Without it a
// passthrough argv can name any path the base command will exec (e.g. a
// dispatcher resolving "../../bin/sh"), including a tool that daemonizes
// out of the adapter's reach.
Tools []string `yaml:"tools,omitempty"`
// Service marks the method as starting a long-running server that the
// adapter owns (see CLIService), instead of a run-to-completion command.
Service *CLIService `yaml:"service,omitempty"`
}

// CLIService declares a method that starts a long-running server. The adapter
// keeps the server as its own foreground child (never daemonized) in its
// process group, returns once the server is ready, and stops it when the
// adapter stops or dies — so no server outlives the app. A server that
// forks itself into the background escapes all of that, which is why
// ForceArgs exists: it is appended last so a last-wins flag such as
// `--daemonize no` overrides anything the caller or a config file set.
type CLIService struct {
// ReadyTCP (enumerated methods only) is a host:port, with ${field}
// placeholders, that accepts a TCP connection once the server is up. The
// call fails fast if something already accepts connections there.
ReadyTCP string `yaml:"ready_tcp,omitempty"`
// ReadyAfter: with no ReadyTCP, the server counts as ready once it has
// stayed up this long (Go duration; default 1s). A process that exits
// sooner (--help, --version, a config error) returns its output like a
// plain command.
ReadyAfter string `yaml:"ready_after,omitempty"`
// ReadyTimeout bounds the wait (default 30s); a server not ready by then
// is stopped and the call fails. Must be below the method's timeout.
ReadyTimeout string `yaml:"ready_timeout,omitempty"`
// LogFile (optional, ${field} placeholders) is where the server writes its
// own log; its tail is returned when the server fails to start.
LogFile string `yaml:"log_file,omitempty"`
// ForceArgs are appended after every other argument.
ForceArgs []string `yaml:"force_args,omitempty"`
// Tools (passthrough only): the args[0] names that start a server; any
// other tool runs as a plain command. Required for a passthrough service.
Tools []string `yaml:"tools,omitempty"`
// ReadyPortFlag (passthrough only): the argv flag that carries the
// server's TCP port, e.g. "--port". When a call passes it (`--port N` or
// `--port=N`), the server is ready once 127.0.0.1:N accepts (and the call
// fails fast if something already does) instead of after ReadyAfter.
ReadyPortFlag string `yaml:"ready_port_flag,omitempty"`
}

// ReadyAfterOrDefault / ReadyTimeoutOrDefault feed the generated Spec.
func (s *CLIService) ReadyAfterOrDefault() string {
if s.ReadyAfter == "" {
return "1s"
}
return s.ReadyAfter
}

func (s *CLIService) ReadyTimeoutOrDefault() string {
if s.ReadyTimeout == "" {
return "30s"
}
return s.ReadyTimeout
}

// Grants tunes the manifest's declared capabilities. The standard set
Expand Down Expand Up @@ -1522,6 +1581,84 @@ func (c *Config) validateCLIMethod(i int, m Method) []error {
case len(m.CLI.Args) == 0 && !m.CLI.ParamsAsFlags:
errs = append(errs, fmt.Errorf("methods[%d] (%s): cli route needs args, params_as_flags, or passthrough", i, m.Name))
}
if m.CLI == nil {
return errs
}
if len(m.CLI.Tools) > 0 && !m.CLI.Passthrough {
errs = append(errs, fmt.Errorf("methods[%d] (%s): cli.tools only applies to a passthrough route", i, m.Name))
}
for _, t := range m.CLI.Tools {
if t == "" || strings.ContainsAny(t, "/\\") || t == "." || t == ".." {
errs = append(errs, fmt.Errorf("methods[%d] (%s): cli.tools entry %q must be a bare tool name", i, m.Name, t))
}
}
if sv := m.CLI.Service; sv != nil {
errs = append(errs, validateCLIService(i, m, sv)...)
}
return errs
}

// validateCLIService checks a cli.service block (see CLIService).
func validateCLIService(i int, m Method, sv *CLIService) []error {
var errs []error
at := fmt.Sprintf("methods[%d] (%s): cli.service", i, m.Name)
durOK := func(name, v string) time.Duration {
if v == "" {
return 0
}
d, err := time.ParseDuration(v)
if err != nil || d <= 0 {
errs = append(errs, fmt.Errorf("%s.%s %q is not a positive Go duration", at, name, v))
return 0
}
return d
}
durOK("ready_after", sv.ReadyAfter)
rt := durOK("ready_timeout", sv.ReadyTimeout)
if rt == 0 {
rt = 30 * time.Second
}
if mt, err := time.ParseDuration(m.TimeoutFor()); err == nil && rt >= mt {
errs = append(errs, fmt.Errorf("%s.ready_timeout %s must be below the method timeout %s", at, rt, mt))
}
if m.CLI.Passthrough {
if len(sv.Tools) == 0 {
errs = append(errs, fmt.Errorf("%s on a passthrough route needs tools (the args[0] names that start a server)", at))
}
if sv.ReadyTCP != "" || sv.LogFile != "" {
errs = append(errs, fmt.Errorf("%s: ready_tcp/log_file need ${field} params, which a passthrough route does not have", at))
}
if f := sv.ReadyPortFlag; f != "" && (!strings.HasPrefix(f, "-") || strings.ContainsAny(f, "= ")) {
errs = append(errs, fmt.Errorf("%s.ready_port_flag %q must be a flag such as --port", at, f))
}
// Without an allowlist a caller could name the server by another path
// (or wrap it in a shell) and start it daemonized, outside the service.
if len(m.CLI.Tools) == 0 {
errs = append(errs, fmt.Errorf("%s on a passthrough route needs cli.tools (the allowlist of args[0] names)", at))
}
allowed := map[string]bool{}
for _, t := range m.CLI.Tools {
allowed[t] = true
}
for _, t := range sv.Tools {
if len(m.CLI.Tools) > 0 && !allowed[t] {
errs = append(errs, fmt.Errorf("%s.tools entry %q is not in cli.tools", at, t))
}
}
} else {
if len(sv.Tools) > 0 {
errs = append(errs, fmt.Errorf("%s.tools only applies to a passthrough route", at))
}
if sv.ReadyPortFlag != "" {
errs = append(errs, fmt.Errorf("%s.ready_port_flag only applies to a passthrough route (use ready_tcp)", at))
}
if sv.ReadyTCP != "" {
host, port, err := net.SplitHostPort(sv.ReadyTCP)
if err != nil || host == "" || port == "" {
errs = append(errs, fmt.Errorf("%s.ready_tcp %q must be host:port (placeholders allowed)", at, sv.ReadyTCP))
}
}
}
return errs
}

Expand Down
6 changes: 5 additions & 1 deletion internal/scaffold/scaffold.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,16 @@ type file struct {

// childProcFiles are the per-OS process attributes the exec runner applies to
// every CLI child (Linux: parent-death signal), plus the guard that takes an
// in-flight child down with a SIGKILLed adapter; emitted with client_cli.go.tmpl.
// in-flight child down with a SIGKILLed adapter, plus the servers cli.service
// methods start and own (service.go, svcproc_*.go); emitted with client_cli.go.tmpl.
func childProcFiles() []file {
return []file{
{filepath.Join("internal", "backend", "childproc_linux.go"), "childproc_linux.go.tmpl"},
{filepath.Join("internal", "backend", "childproc_other.go"), "childproc_other.go.tmpl"},
{filepath.Join("internal", "backend", "childguard.go"), "childguard.go.tmpl"},
{filepath.Join("internal", "backend", "service.go"), "service.go.tmpl"},
{filepath.Join("internal", "backend", "svcproc_linux.go"), "svcproc_linux.go.tmpl"},
{filepath.Join("internal", "backend", "svcproc_other.go"), "svcproc_other.go.tmpl"},
}
}

Expand Down
35 changes: 35 additions & 0 deletions internal/scaffold/templates/client_cli.go.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,13 @@ type Spec struct {
Args []string // may contain ${field} placeholders, substituted from params
ParamsAsFlags bool // if true, append --key value for each payload field (sorted)
Passthrough bool // if true, ignore Args; argv comes from the call's "args" array
// Tools (passthrough) allowlists the call's args[0]: it must be exactly one
// of these bare names, so a passthrough argv cannot name an arbitrary path
// for the base command to run (see checkTool).
Tools []string
// Service marks a method that starts a long-running server the adapter
// owns (service.go); nil for a run-to-completion command.
Service *ServiceSpec
}

// Call carries one invocation's dynamic inputs, decoded from the IPC payload.
Expand Down Expand Up @@ -97,6 +104,9 @@ func (r *Runner) Run(ctx context.Context, spec Spec, call Call) (json.RawMessage

args := append([]string(nil), r.base[1:]...)
if spec.Passthrough {
if err := checkTool(spec.Tools, call.Args); err != nil {
return nil, err
}
args = append(args, call.Args...)
} else {
for _, a := range spec.Args {
Expand All @@ -113,6 +123,10 @@ func (r *Runner) Run(ctx context.Context, spec Spec, call Call) (json.RawMessage
}
}

if sv := spec.Service; sv != nil && sv.appliesTo(spec, call) {
return r.startService(ctx, sv, args, call)
}

cmd := exec.CommandContext(ctx, r.base[0], args...)
cmd.Env = r.childEnv()
// On timeout/cancel, ask the CLI to stop (SIGTERM) before exec's WaitDelay
Expand Down Expand Up @@ -188,6 +202,27 @@ func (r *Runner) childEnv() []string {
return env
}

// checkTool enforces a passthrough route's tools allowlist (no-op without one).
// A dispatcher base command (e.g. a `redis` script that runs "$dir/$1") would
// otherwise resolve "../../bin/sh" and run any host binary.
func checkTool(tools, args []string) error {
if len(tools) == 0 {
return nil
}
if len(args) > 0 {
for _, t := range tools {
if args[0] == t {
return nil
}
}
}
got := "(none)"
if len(args) > 0 {
got = fmt.Sprintf("%q", args[0])
}
return fmt.Errorf("backend: args[0] must be one of %s; got %s", strings.Join(tools, ", "), got)
}

// substitute replaces every ${field} token in s with params[field]. An
// unresolved token is an error rather than a silently-empty arg, so a missing
// required param fails loudly instead of mis-invoking the CLI.
Expand Down
8 changes: 6 additions & 2 deletions internal/scaffold/templates/main.go.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,10 @@ func main() {

serveErr := serve(ctx, *socket, d)
{{- if or (eq .Backend.Type "cli") (eq .Backend.Type "hybrid")}}
// Servers started by cli.service methods run until the app stops: stop
// them now (SIGTERM, then SIGKILL after a grace period). Before the guard
// is closed, since a running server holds it.
backend.StopServices()
backend.CloseChildGuard(2 * time.Second)
{{- end}}
if serveErr != nil {
Expand Down Expand Up @@ -729,7 +733,7 @@ func jsonScalar(v json.RawMessage) string {
func registerHandlers(d *ipc.Dispatcher, r *backend.Runner, c *backend.CloudClient, version, backendURL string) {
{{- range .Methods}}
{{- if .CLI}}
d.Register("{{.Name}}", forwardExec(r, backend.Spec{ {{if .CLI.Passthrough}}Passthrough: true{{else}}Args: {{printf "%#v" .CLI.Args}}, ParamsAsFlags: {{.CLI.ParamsAsFlags}}{{end}} }, dur("{{.TimeoutFor}}"))) // {{.Duration}} (local)
d.Register("{{.Name}}", forwardExec(r, backend.Spec{ {{if .CLI.Passthrough}}Passthrough: true{{if .CLI.Tools}}, Tools: {{printf "%#v" .CLI.Tools}}{{end}}{{else}}Args: {{printf "%#v" .CLI.Args}}, ParamsAsFlags: {{.CLI.ParamsAsFlags}}{{end}}{{with .CLI.Service}}, Service: &backend.ServiceSpec{ReadyTCP: {{printf "%q" .ReadyTCP}}, ReadyAfter: dur({{printf "%q" .ReadyAfterOrDefault}}), ReadyTimeout: dur({{printf "%q" .ReadyTimeoutOrDefault}}), LogFile: {{printf "%q" .LogFile}}, ForceArgs: {{printf "%#v" .ForceArgs}}, Tools: {{printf "%#v" .Tools}}, ReadyPortFlag: {{printf "%q" .ReadyPortFlag}}}{{end}} }, dur("{{.TimeoutFor}}"))) // {{.Duration}} (local)
{{- else}}
d.Register("{{.Name}}", cloudHandler(c, "{{.HTTP.Verb}}", "{{.HTTP.Path}}", {{if eq .HTTP.Path $.ProvisionPath}}true{{else}}false{{end}}, dur("{{.TimeoutFor}}"))) // {{.Duration}} (cloud)
{{- end}}
Expand Down Expand Up @@ -823,7 +827,7 @@ func decodeCall(spec backend.Spec, raw json.RawMessage) (backend.Call, error) {
{{- else}}
func registerHandlers(d *ipc.Dispatcher, r *backend.Runner, version string) {
{{- range .Methods}}
d.Register("{{.Name}}", forwardExec(r, backend.Spec{ {{if .CLI.Passthrough}}Passthrough: true{{else}}Args: {{printf "%#v" .CLI.Args}}, ParamsAsFlags: {{.CLI.ParamsAsFlags}}{{end}} }, dur("{{.TimeoutFor}}"))) // {{.Duration}}
d.Register("{{.Name}}", forwardExec(r, backend.Spec{ {{if .CLI.Passthrough}}Passthrough: true{{if .CLI.Tools}}, Tools: {{printf "%#v" .CLI.Tools}}{{end}}{{else}}Args: {{printf "%#v" .CLI.Args}}, ParamsAsFlags: {{.CLI.ParamsAsFlags}}{{end}}{{with .CLI.Service}}, Service: &backend.ServiceSpec{ReadyTCP: {{printf "%q" .ReadyTCP}}, ReadyAfter: dur({{printf "%q" .ReadyAfterOrDefault}}), ReadyTimeout: dur({{printf "%q" .ReadyTimeoutOrDefault}}), LogFile: {{printf "%q" .LogFile}}, ForceArgs: {{printf "%#v" .ForceArgs}}, Tools: {{printf "%#v" .Tools}}, ReadyPortFlag: {{printf "%q" .ReadyPortFlag}}}{{end}} }, dur("{{.TimeoutFor}}"))) // {{.Duration}}
{{- end}}
d.Register("{{.Namespace}}.help", helpHandler(version))
}
Expand Down
Loading
Loading