1 // Simple RPC-like protocol: establish new connection and upload helper
3 // SPDX-License-Identifier: GPL-3.0-or-later
4 // Copyright (C) 2021-2024 Simon Ruderich
20 "ruderich.org/simon/safcm"
23 type SSHConfig struct {
25 User string // optional
26 SshConfig string // optional
31 func (c *Conn) DialSSH(cfg SSHConfig) error {
33 return fmt.Errorf("cannot reuse Conn")
36 if cfg.RemoteHelpers == nil {
37 return fmt.Errorf("SSHConfig.RemoteHelpers not set")
39 c.remoteHelpers = cfg.RemoteHelpers
43 remote = cfg.User + "@" + cfg.Host
45 c.debugf("DialSSH: connecting to %q", remote)
49 // Help debugging by showing executed shell commands
54 if cfg.SshConfig != "" {
55 c.sshOpts = []string{"-F", cfg.SshConfig}
57 c.cmd = exec.Command("ssh",
58 append(append([]string{}, c.sshOpts...),
59 c.sshRemote, "/bin/sh", opts)...)
61 stdin, err := c.cmd.StdinPipe()
65 stdout, err := c.cmd.StdoutPipe()
69 err = c.handleStderrAsEvents(c.cmd)
79 err = c.dialSSH(stdin, stdout)
81 c.Kill() //nolint:errcheck
84 c.conn = safcm.NewGobConn(stdout, stdin)
89 func (c *Conn) dialSSH(stdin io.Writer, stdout_ io.Reader) error {
90 stdout := bufio.NewReader(stdout_)
92 goos, err := connGetGoos(stdin, stdout)
96 goarch, err := connGetGoarch(stdin, stdout)
100 uid, err := connGetUID(stdin, stdout)
105 path := fmt.Sprintf("/tmp/safcm-remote-%d", uid)
107 c.debugf("DialSSH: probing remote at %q", path)
109 // Compatibility for different operating systems
114 dir_stat='drwxrwxrwt 0 0'
115 file_stat="-rwx------ $(id -u) $(id -g)"
117 stat -c '%A %u %g' "$1"
123 case "freebsd", "openbsd":
126 file_stat="100700 $(id -u) $(id -g)"
128 stat -f '%p %u %g' "$1"
135 return fmt.Errorf("internal error: no support for %q", goos)
138 // Use a function so the shell cannot execute the input line-wise.
139 // This is important because we're also using stdin to send data to
140 // the script. If the shell executes the input line-wise then our
141 // script is interpreted as input for `read`.
143 // The target directory must no permit other users to delete our files
144 // or symlink attacks and arbitrary code execution is possible. For
145 // /tmp this is guaranteed by the sticky bit. The code verifies the
146 // directory has the proper permissions.
148 // We cannot use `test -f && test -O` because this is open to TOCTOU
149 // attacks. `stat` gives use the full file state. If the file is owned
150 // by us and not a symlink then it's safe to use (assuming sticky
151 // directory or directory not writable by others).
153 // `test -e` is only used to prevent error messages if the file
154 // doesn't exist. It does not guard against any races.
155 _, err = fmt.Fprintf(stdin, `
160 dir="$(dirname "$x")"
161 if ! test "$(compat_stat "$dir")" = "$dir_stat"; then
162 echo "unsafe permissions on $dir, aborting" >&2
166 if test -e "$x" && test "$(compat_stat "$x")" = "$file_stat"; then
168 compat_sha512sum "$x"
170 # Empty checksum to request upload
174 # Wait for signal to continue
177 if test -n "$upload"; then
178 tmp="$(mktemp "$x.XXXXXX")"
179 # Report filename for upload
181 # Wait for upload to complete
184 # Safely create new file (ln does not follow symlinks)
188 # Make file executable
190 # Some BSD create files with group wheel in /tmp
191 chgrp "$(id -g)" "$x"
201 remoteSum, err := stdout.ReadString('\n')
206 // Get remote helper binary
207 helper, err := fs.ReadFile(c.remoteHelpers,
208 fmt.Sprintf("%s-%s", goos, goarch))
210 return fmt.Errorf("remote not built for GOOS/GOARCH %s/%s",
215 if remoteSum == "\n" {
217 c.debugf("DialSSH: remote not present or invalid permissions")
220 x := strings.Fields(remoteSum)
222 return fmt.Errorf("got unexpected checksum line %q",
225 sha := sha512.Sum512(helper)
226 hex := hex.EncodeToString(sha[:])
228 c.debugf("DialSSH: remote checksum matches")
231 c.debugf("DialSSH: remote checksum does not match")
236 // Notify user that an upload is going to take place.
237 c.events <- ConnEvent{
238 Type: ConnEventUpload,
241 // Tell script we want to upload a new file.
242 _, err = fmt.Fprintln(stdin, "upload")
246 // Get path to temporary file for upload.
248 // Write to the temporary file instead of the final path so
249 // that a concurrent run of this function won't use a
250 // partially written file. The rm in the script could still
251 // cause a missing file but at least no file with unknown
252 // content is executed.
253 path, err := stdout.ReadString('\n')
257 path = strings.TrimSuffix(path, "\n")
259 c.debugf("DialSSH: uploading new remote to %q at %q",
262 cmd := exec.Command("ssh",
263 append(append([]string{}, c.sshOpts...),
265 fmt.Sprintf("cat > %q", path))...)
266 cmd.Stdin = bytes.NewReader(helper)
267 err = c.handleStderrAsEvents(cmd) // cmd.Stderr
277 // Tell script to continue and execute the remote helper
278 _, err = fmt.Fprintln(stdin, "")
286 func connGetGoos(stdin io.Writer, stdout *bufio.Reader) (string, error) {
287 _, err := fmt.Fprintln(stdin, "uname")
291 x, err := stdout.ReadString('\n')
295 x = strings.TrimSpace(x)
297 // NOTE: Adapt helper uploading in dialSSH() when adding new systems
307 return "", fmt.Errorf("unsupported OS %q (`uname`)", x)
312 func connGetGoarch(stdin io.Writer, stdout *bufio.Reader) (string, error) {
313 _, err := fmt.Fprintln(stdin, "uname -m")
317 x, err := stdout.ReadString('\n')
321 x = strings.TrimSpace(x)
323 // NOTE: Adapt cmd/safcm-remote/build.sh when adding new architectures
326 case "x86_64", "amd64":
331 return "", fmt.Errorf("unsupported arch %q (`uname -m`)", x)
336 func connGetUID(stdin io.Writer, stdout *bufio.Reader) (int, error) {
337 _, err := fmt.Fprintln(stdin, "id -u")
341 x, err := stdout.ReadString('\n')
345 x = strings.TrimSpace(x)
347 uid, err := strconv.Atoi(x)
349 return -1, fmt.Errorf("invalid UID %q (`id -u`)", x)