From ebccfa046705f5a0ab159e8001fcaabb02a3bbb6 Mon Sep 17 00:00:00 2001 From: "Claude (noether)" Date: Thu, 23 Jul 2026 11:59:26 +0200 Subject: [PATCH] =?UTF-8?q?sic:=20v2=20main()=20=E2=80=94=20nested=20targe?= =?UTF-8?q?ts,=20boundary-preserved=20argv,=20real=20stdin=20forwarding?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ]). * stdin is forwarded AFTER the frame (the v1 client set Stdin to the frame alone, so `sic host cat >f 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. --- cmd/sic/main.go | 193 ++++++++++++++++++++++++++++------------------- cmd/sicd/main.go | 9 ++- 2 files changed, 121 insertions(+), 81 deletions(-) diff --git a/cmd/sic/main.go b/cmd/sic/main.go index fba4670..0cee2ab 100644 --- a/cmd/sic/main.go +++ b/cmd/sic/main.go @@ -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] [ ...] -// sic [--sh] -- [ ...] (explicit separator) +// sic [--sh] [--] [ ...] // -// 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] [--] [ ...]") + 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] [ ...]") - 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'