1 // Simple RPC-like protocol: establish new connection and upload helper
3 // Copyright (C) 2021 Simon Ruderich
5 // This program is free software: you can redistribute it and/or modify
6 // it under the terms of the GNU General Public License as published by
7 // the Free Software Foundation, either version 3 of the License, or
8 // (at your option) any later version.
10 // This program is distributed in the hope that it will be useful,
11 // but WITHOUT ANY WARRANTY; without even the implied warranty of
12 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 // GNU General Public License for more details.
15 // You should have received a copy of the GNU General Public License
16 // along with this program. If not, see <http://www.gnu.org/licenses/>.
31 "ruderich.org/simon/safcm"
32 "ruderich.org/simon/safcm/remote"
35 func (c *Conn) DialSSH(user, host string) error {
37 return fmt.Errorf("cannot reuse Conn")
42 remote = user + "@" + host
44 c.debugf("DialSSH: connecting to %q", remote)
48 // Help debugging by showing executed shell commands
51 c.cmd = exec.Command("ssh", remote, "/bin/sh", opts)
54 stdin, err := c.cmd.StdinPipe()
58 stdout, err := c.cmd.StdoutPipe()
62 err = c.handleStderrAsEvents(c.cmd)
72 err = c.dialSSH(stdin, stdout)
77 c.conn = safcm.NewGobConn(stdout, stdin)
82 func (c *Conn) dialSSH(stdin io.Writer, stdout_ io.Reader) error {
83 stdout := bufio.NewReader(stdout_)
85 goos, err := connGetGoos(stdin, stdout)
89 goarch, err := connGetGoarch(stdin, stdout)
93 uid, err := connGetUID(stdin, stdout)
98 path := fmt.Sprintf("/tmp/safcm-remote-%d", uid)
100 c.debugf("DialSSH: probing remote at %q", path)
101 // Use a function so the shell cannot execute the input line-wise.
102 // This is important because we're also using stdin to send data to
103 // the script. If the shell executes the input line-wise then our
104 // script is interpreted as input for `read`.
106 // The target directory must no permit other users to delete our files
107 // or symlink attacks and arbitrary code execution is possible. For
108 // /tmp this is guaranteed by the sticky bit. Make sure it has the
109 // proper permissions.
111 // We cannot use `test -f && test -O` because this is open to TOCTOU
112 // attacks. `stat` gives use the full file state. If the file is owned
113 // by us and not a symlink then it's safe to use (assuming sticky or
114 // directory not writable by others).
116 // `test -e` is only used to prevent error messages if the file
117 // doesn't exist. It does not guard against any races.
118 _, err = fmt.Fprintf(stdin, `
122 dir="$(dirname "$x")"
123 if ! test "$(stat -c '%%A %%u %%g' "$dir")" = 'drwxrwxrwt 0 0'; then
124 echo "unsafe permissions on $dir, aborting" >&2
128 if test -e "$x" && test "$(stat -c '%%A %%u' "$x")" = "-rwx------ $(id -u)"; then
132 # Empty checksum to request upload
136 # Wait for signal to continue
139 if test -n "$upload"; then
140 tmp="$(mktemp "$x.XXXXXX")"
141 # Report filename for upload
144 # Wait for upload to complete
147 # Safely create new file (ln does not follow symlinks)
151 # Make file executable
162 remoteSum, err := stdout.ReadString('\n')
167 // Get embedded helper binary
168 helper, err := remote.Helpers.ReadFile(
169 fmt.Sprintf("helpers/%s-%s", goos, goarch))
171 return fmt.Errorf("remote not built for GOOS/GOARCH %s/%s",
176 if remoteSum == "\n" {
178 c.debugf("DialSSH: remote not present or invalid permissions")
181 x := strings.Fields(remoteSum)
183 return fmt.Errorf("got unexpected checksum line %q",
186 sha := sha512.Sum512(helper)
187 hex := hex.EncodeToString(sha[:])
189 c.debugf("DialSSH: remote checksum matches")
192 c.debugf("DialSSH: remote checksum does not match")
197 // Notify user that an upload is going to take place.
198 c.events <- ConnEvent{
199 Type: ConnEventUpload,
202 // Tell script we want to upload a new file.
203 _, err = fmt.Fprintln(stdin, "upload")
207 // Get path to temporary file for upload.
209 // Write to the temporary file instead of the final path so
210 // that a concurrent run of this function won't use a
211 // partially written file. The rm in the script could still
212 // cause a missing file but at least no file with unknown
213 // content is executed.
214 path, err := stdout.ReadString('\n')
218 path = strings.TrimSuffix(path, "\n")
220 c.debugf("DialSSH: uploading new remote to %q at %q",
223 cmd := exec.Command("ssh", c.remote,
224 fmt.Sprintf("cat > %q", path))
225 cmd.Stdin = bytes.NewReader(helper)
226 err = c.handleStderrAsEvents(cmd)
236 // Tell script to continue and execute the remote helper
237 _, err = fmt.Fprintln(stdin, "")
245 func connGetGoos(stdin io.Writer, stdout *bufio.Reader) (string, error) {
246 _, err := fmt.Fprintln(stdin, "uname -o")
250 x, err := stdout.ReadString('\n')
254 x = strings.TrimSpace(x)
256 // NOTE: Adapt helper uploading in dialSSH() when adding new systems
262 return "", fmt.Errorf("unsupported OS %q (`uname -o`)", x)
267 func connGetGoarch(stdin io.Writer, stdout *bufio.Reader) (string, error) {
268 _, err := fmt.Fprintln(stdin, "uname -m")
272 x, err := stdout.ReadString('\n')
276 x = strings.TrimSpace(x)
278 // NOTE: Adapt cmd/safcm-remote/build.sh when adding new architectures
286 return "", fmt.Errorf("unsupported arch %q (`uname -m`)", x)
291 func connGetUID(stdin io.Writer, stdout *bufio.Reader) (int, error) {
292 _, err := fmt.Fprintln(stdin, "id -u")
296 x, err := stdout.ReadString('\n')
300 x = strings.TrimSpace(x)
302 uid, err := strconv.Atoi(x)
304 return -1, fmt.Errorf("invalid UID %q (`id -u`)", x)