sic: v2 main() — nested targets, boundary-preserved argv, real stdin forwarding
WP8, the last code before deploy. Rewrites the client entrypoint on the v2 wire:
* target parsing: host[/hop1/hop2...]; hops resolved to incus/docker/pct verbs via
/etc/sic/hosts.toml (loaded only when hops are present), folded into the frame onion
by buildChain — verbs[0] is the outermost layer the host sicd enters first.
* argv is carried as [][]byte end to end and NEVER space-joined; --sh is the sole
exception (user asked for a shell line -> [sh -c <joined>]).
* stdin is forwarded AFTER the frame (the v1 client set Stdin to the frame alone, so
`sic host cat >f <local` wrote a zero-byte file). The copy runs in an abandoned
goroutine so a stdin-ignoring command on a tty never blocks on a read that never EOFs.
* ssh -T: the frame is binary; a pty would mangle it.
* exit code inherited from the far end.
Dead v1 framing (netstring/frameArgv/indexOf) removed — frame.go owns the wire now.
Also corrected the stale runV2 doc comment in sicd (still described the removed 0:,
terminator; it is argc-prefixed since e1f93de).
E2e on boltzmann (ssh localhost + real sicd), all green: touch "a b" -> ONE file;
echo A "" B -> "A B"; pipe+redirect stdin both forward; exit 7 propagates; a
stdin-ignoring echo does not hang; --sh; and netstring-looking args (3:x, a,b:c)
survive as single arguments.
This commit is contained in:
+116
-77
@@ -1,107 +1,146 @@
|
||||
// sic — frame argv as netstrings and pipe to sicd on a remote host.
|
||||
// sic — preserve argv boundaries across ssh (and nested container hops) by framing
|
||||
// the command as length-delimited netstrings and piping it to sicd on the far end.
|
||||
//
|
||||
// Each argument is framed as a netstring and written to sicd's stdin over ssh.
|
||||
// The netstring framing is done here so callers pass argv on the command line
|
||||
// and never construct frames by hand.
|
||||
// The whole point: `sic host touch 'a b'` creates ONE file, never two. No argument is
|
||||
// ever space-joined or re-split on the way to the remote exec (the one exception is
|
||||
// --sh, where the user explicitly asked for a shell line).
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// sic [--sh] <host> <command> [<arg> ...]
|
||||
// sic [--sh] <host> -- <command> [<arg> ...] (explicit separator)
|
||||
// sic [--sh] <target> [--] <command> [<arg> ...]
|
||||
//
|
||||
// Examples:
|
||||
// A target is a host, optionally followed by container hops separated by '/':
|
||||
//
|
||||
// sic host1 echo hello world
|
||||
// sic host1 touch 'a b' # ONE file, not two
|
||||
// sic --sh host1 'echo hi | wc -c'
|
||||
// sic host echo hi # run on the host
|
||||
// sic host touch 'a b' # ONE file
|
||||
// sic dcw2/noether cat /etc/os-release # into the 'noether' guest on dcw2
|
||||
// sic data/ct110/app id # host -> pct ct110 -> docker app (two hops)
|
||||
// sic --sh host 'echo hi | wc -c' # shell line (space-join is intended here)
|
||||
//
|
||||
// Hop runtimes come from /etc/sic/hosts.toml, one stanza per host:
|
||||
//
|
||||
// [dcw2]
|
||||
// nest = ["incus"]
|
||||
// [data]
|
||||
// nest = ["pct", "docker"]
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// netstring encodes a UTF-8 string as a netstring (djb format).
|
||||
func netstring(s string) string {
|
||||
encoded := []byte(s)
|
||||
return strconv.Itoa(len(encoded)) + ":" + string(encoded) + ","
|
||||
func fatal(format string, a ...interface{}) {
|
||||
fmt.Fprintf(os.Stderr, "sic: "+format+"\n", a...)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// frameArgv builds a single netstring command frame from args.
|
||||
func frameArgv(args []string, mode string) []byte {
|
||||
var sb strings.Builder
|
||||
sb.WriteString(netstring(mode))
|
||||
switch mode {
|
||||
case "exec":
|
||||
for _, a := range args {
|
||||
sb.WriteString(netstring(a))
|
||||
}
|
||||
case "sh":
|
||||
sb.WriteString(netstring(strings.Join(args, " ")))
|
||||
func usage() {
|
||||
fmt.Fprintln(os.Stderr, "Usage: sic [--sh] <target> [--] <command> [<arg> ...]")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// loadNest returns the runtime nest for a host from /etc/sic/hosts.toml. Only called when
|
||||
// the target actually has hops — a bare host never needs the config file.
|
||||
func loadNest(host string) ([]string, error) {
|
||||
const path = "/etc/sic/hosts.toml"
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("target has hops but %s is unreadable: %w", path, err)
|
||||
}
|
||||
sb.WriteString("0:,")
|
||||
return []byte(sb.String())
|
||||
cfg, err := parseHostsConfig(string(data))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
nest, ok := cfg[host]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("no [%s] stanza in %s (needed to resolve hops)", host, path)
|
||||
}
|
||||
return nest, nil
|
||||
}
|
||||
|
||||
func main() {
|
||||
argv := os.Args[1:]
|
||||
args := os.Args[1:]
|
||||
|
||||
// Parse flags
|
||||
mode := "exec"
|
||||
for len(argv) > 0 && strings.HasPrefix(argv[0], "--") {
|
||||
switch argv[0] {
|
||||
case "--sh":
|
||||
mode = "sh"
|
||||
case "--":
|
||||
argv = argv[1:]
|
||||
goto parseArgs
|
||||
}
|
||||
argv = argv[1:]
|
||||
// --sh is the only leading flag. Everything after the target is the command.
|
||||
shMode := false
|
||||
if len(args) > 0 && args[0] == "--sh" {
|
||||
shMode = true
|
||||
args = args[1:]
|
||||
}
|
||||
if len(args) < 1 {
|
||||
usage()
|
||||
}
|
||||
|
||||
parseArgs:
|
||||
if len(argv) == 0 {
|
||||
fmt.Fprintln(os.Stderr, "Usage: sic [--sh] <host> <command> [<arg> ...]")
|
||||
os.Exit(1)
|
||||
target := args[0]
|
||||
rest := args[1:]
|
||||
// Optional explicit separator so a command whose first token looks like a flag survives.
|
||||
if len(rest) > 0 && rest[0] == "--" {
|
||||
rest = rest[1:]
|
||||
}
|
||||
if len(rest) == 0 {
|
||||
fatal("missing command")
|
||||
}
|
||||
|
||||
// Locate host and command
|
||||
var host string
|
||||
var command []string
|
||||
|
||||
if idx := indexOf(argv, "--"); idx >= 0 {
|
||||
host = argv[0]
|
||||
command = argv[idx+1:]
|
||||
} else if len(argv) >= 2 {
|
||||
host = argv[0]
|
||||
command = argv[1:]
|
||||
// Build the command argv, boundary-preserved. --sh is the sole place a space-join is
|
||||
// correct: the user asked for a shell line, not an argv.
|
||||
var cmdArgv [][]byte
|
||||
if shMode {
|
||||
cmdArgv = [][]byte{[]byte("sh"), []byte("-c"), []byte(strings.Join(rest, " "))}
|
||||
} else {
|
||||
fmt.Fprintln(os.Stderr, "sic: missing command arguments")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
frameData := frameArgv(command, mode)
|
||||
sshCmd := exec.Command("ssh", "-o", "StrictHostKeyChecking=no", host, "sicd")
|
||||
sshCmd.Stdin = strings.NewReader(string(frameData))
|
||||
sshCmd.Stdout = os.Stdout
|
||||
sshCmd.Stderr = os.Stderr
|
||||
if err := sshCmd.Run(); err != nil {
|
||||
if exitErr, ok := err.(*exec.ExitError); ok {
|
||||
os.Exit(exitErr.ExitCode())
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "sic: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func indexOf(slice []string, s string) int {
|
||||
for i, v := range slice {
|
||||
if v == s {
|
||||
return i
|
||||
cmdArgv = make([][]byte, len(rest))
|
||||
for i, a := range rest {
|
||||
cmdArgv[i] = []byte(a)
|
||||
}
|
||||
}
|
||||
return -1
|
||||
|
||||
host, hops := parseTarget(target)
|
||||
|
||||
var verbs []string
|
||||
if len(hops) > 0 {
|
||||
nest, err := loadNest(host)
|
||||
if err != nil {
|
||||
fatal("%v", err)
|
||||
}
|
||||
verbs, err = resolveVerbs(hops, nest)
|
||||
if err != nil {
|
||||
fatal("%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
frame := buildChain(verbs, cmdArgv, nil)
|
||||
|
||||
// ssh -T: never a pty — the frame is binary and a pty would mangle it (CR/LF, echo).
|
||||
cmd := exec.Command("ssh", "-T", "-o", "StrictHostKeyChecking=no", host, "sicd")
|
||||
stdin, err := cmd.StdinPipe()
|
||||
if err != nil {
|
||||
fatal("stdin pipe: %v", err)
|
||||
}
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
if err := cmd.Start(); err != nil {
|
||||
fatal("ssh: %v", err)
|
||||
}
|
||||
|
||||
// Write the onion, THEN forward our own stdin (the v1 client set Stdin to the frame alone
|
||||
// and dropped everything after — so `sic host 'cat >f' <local` wrote a zero-byte file).
|
||||
// The copy runs in a goroutine we abandon on exit: a command that ignores stdin
|
||||
// (`sic host echo hi` on a tty) must not block on a tty read that never EOFs.
|
||||
if _, err := stdin.Write(frame); err != nil {
|
||||
fatal("write frame: %v", err)
|
||||
}
|
||||
go func() {
|
||||
io.Copy(stdin, os.Stdin)
|
||||
stdin.Close()
|
||||
}()
|
||||
|
||||
if err := cmd.Wait(); err != nil {
|
||||
if ee, ok := err.(*exec.ExitError); ok {
|
||||
os.Exit(ee.ExitCode())
|
||||
}
|
||||
fatal("ssh: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
+5
-4
@@ -197,10 +197,11 @@ func runV2() {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// v2 content = argv netstrings + the empty-netstring terminator 0:, + payload. Length-framed
|
||||
// argv PRESERVES argument boundaries (sic's founding guarantee: `touch 'a b'` is ONE file)
|
||||
// and lets ANY byte appear in an argument — unlike the old space-split. Everything after the
|
||||
// terminator is the payload (a nested frame for the next hop, or empty).
|
||||
// v2 content = netstring(argc) + argc argv netstrings + payload. Length-framed argv PRESERVES
|
||||
// argument boundaries (sic's founding guarantee: `touch 'a b'` is ONE file) and lets ANY byte
|
||||
// appear in an argument — unlike the old space-split. The explicit argc (not an empty-netstring
|
||||
// terminator) makes an empty "" argument representable (reviewer 964 #1); everything after the
|
||||
// argc argv netstrings is the payload (a nested frame for the next hop, or empty).
|
||||
argv, payload, ok := parseV2Content(content)
|
||||
if !ok {
|
||||
fmt.Fprintf(os.Stderr, "sicd: invalid v2 content (argv framing)\n")
|
||||
|
||||
Reference in New Issue
Block a user