]> ruderich.org/simon Gitweb - safcm/safcm.git/blob - rpc/dial.go
945a75ff86c365fd1e251e205a0b68efbd2b3aef
[safcm/safcm.git] / rpc / dial.go
1 // Simple RPC-like protocol: establish new connection and upload helper
2
3 // Copyright (C) 2021  Simon Ruderich
4 //
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.
9 //
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.
14 //
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/>.
17
18 package rpc
19
20 import (
21         "bufio"
22         "bytes"
23         "crypto/sha512"
24         "encoding/hex"
25         "fmt"
26         "io"
27         "os/exec"
28         "strconv"
29         "strings"
30
31         "ruderich.org/simon/safcm"
32         "ruderich.org/simon/safcm/remote"
33 )
34
35 func (c *Conn) DialSSH(user, host, sshConfig string) error {
36         if c.events == nil {
37                 return fmt.Errorf("cannot reuse Conn")
38         }
39
40         remote := host
41         if user != "" {
42                 remote = user + "@" + host
43         }
44         c.debugf("DialSSH: connecting to %q", remote)
45
46         opts := "-eu"
47         if c.debug {
48                 // Help debugging by showing executed shell commands
49                 opts += "x"
50         }
51
52         c.sshRemote = remote
53         if sshConfig != "" {
54                 c.sshOpts = append(c.sshOpts, "-F", sshConfig)
55         }
56         c.cmd = exec.Command("ssh",
57                 append(append([]string{}, c.sshOpts...),
58                         c.sshRemote, "/bin/sh", opts)...)
59
60         stdin, err := c.cmd.StdinPipe()
61         if err != nil {
62                 return err
63         }
64         stdout, err := c.cmd.StdoutPipe()
65         if err != nil {
66                 return err
67         }
68         err = c.handleStderrAsEvents(c.cmd)
69         if err != nil {
70                 return err
71         }
72
73         err = c.cmd.Start()
74         if err != nil {
75                 return err
76         }
77
78         err = c.dialSSH(stdin, stdout)
79         if err != nil {
80                 c.Kill()
81                 return err
82         }
83         c.conn = safcm.NewGobConn(stdout, stdin)
84
85         return nil
86 }
87
88 func (c *Conn) dialSSH(stdin io.Writer, stdout_ io.Reader) error {
89         stdout := bufio.NewReader(stdout_)
90
91         goos, err := connGetGoos(stdin, stdout)
92         if err != nil {
93                 return err
94         }
95         goarch, err := connGetGoarch(stdin, stdout)
96         if err != nil {
97                 return err
98         }
99         uid, err := connGetUID(stdin, stdout)
100         if err != nil {
101                 return err
102         }
103
104         path := fmt.Sprintf("/tmp/safcm-remote-%d", uid)
105
106         c.debugf("DialSSH: probing remote at %q", path)
107
108         // Compatibility for different operating systems
109         var compat string
110         switch goos {
111         case "linux":
112                 compat = `
113 dir_stat='drwxrwxrwt 0 0'
114 file_stat="-rwx------ $(id -u) $(id -g)"
115 compat_stat() {
116         stat -c '%A %u %g' "$1"
117 }
118 compat_sha512sum() {
119         sha512sum "$1"
120 }
121 `
122         case "freebsd", "openbsd":
123                 compat = `
124 dir_stat='41777 0 0'
125 file_stat="100700 $(id -u) $(id -g)"
126 compat_stat() {
127         stat -f '%p %u %g' "$1"
128 }
129 compat_sha512sum() {
130         sha512 -q "$1"
131 }
132 `
133         default:
134                 return fmt.Errorf("internal error: no support for %q", goos)
135         }
136
137         // Use a function so the shell cannot execute the input line-wise.
138         // This is important because we're also using stdin to send data to
139         // the script. If the shell executes the input line-wise then our
140         // script is interpreted as input for `read`.
141         //
142         // The target directory must no permit other users to delete our files
143         // or symlink attacks and arbitrary code execution is possible. For
144         // /tmp this is guaranteed by the sticky bit. Make sure it has the
145         // proper permissions.
146         //
147         // We cannot use `test -f && test -O` because this is open to TOCTOU
148         // attacks. `stat` gives use the full file state. If the file is owned
149         // by us and not a symlink then it's safe to use (assuming sticky or
150         // directory not writable by others).
151         //
152         // `test -e` is only used to prevent error messages if the file
153         // doesn't exist. It does not guard against any races.
154         _, err = fmt.Fprintf(stdin, `
155 %s
156 f() {
157         x=%q
158
159         dir="$(dirname "$x")"
160         if ! test "$(compat_stat "$dir")" = "$dir_stat"; then
161                 echo "unsafe permissions on $dir, aborting" >&2
162                 exit 1
163         fi
164
165         if test -e "$x" && test "$(compat_stat "$x")" = "$file_stat"; then
166                 # Report checksum
167                 compat_sha512sum "$x"
168         else
169                 # Empty checksum to request upload
170                 echo
171         fi
172
173         # Wait for signal to continue
174         read upload
175
176         if test -n "$upload"; then
177                 tmp="$(mktemp "$x.XXXXXX")"
178                 # Report filename for upload
179                 echo "$tmp"
180
181                 # Wait for upload to complete
182                 read unused
183
184                 # Safely create new file (ln does not follow symlinks)
185                 rm -f "$x"
186                 ln "$tmp" "$x"
187                 rm "$tmp"
188                 # Make file executable
189                 chmod 0700 "$x"
190                 # Some BSD create files with group wheel in /tmp
191                 chgrp "$(id -g)" "$x"
192         fi
193
194         exec "$x" sync
195 }
196 f
197 `, compat, path)
198         if err != nil {
199                 return err
200         }
201         remoteSum, err := stdout.ReadString('\n')
202         if err != nil {
203                 return err
204         }
205
206         // Get embedded helper binary
207         helper, err := remote.Helpers.ReadFile(
208                 fmt.Sprintf("helpers/%s-%s", goos, goarch))
209         if err != nil {
210                 return fmt.Errorf("remote not built for GOOS/GOARCH %s/%s",
211                         goos, goarch)
212         }
213
214         var upload bool
215         if remoteSum == "\n" {
216                 upload = true
217                 c.debugf("DialSSH: remote not present or invalid permissions")
218
219         } else {
220                 x := strings.Fields(remoteSum)
221                 if len(x) < 1 {
222                         return fmt.Errorf("got unexpected checksum line %q",
223                                 remoteSum)
224                 }
225                 sha := sha512.Sum512(helper)
226                 hex := hex.EncodeToString(sha[:])
227                 if hex == x[0] {
228                         c.debugf("DialSSH: remote checksum matches")
229                 } else {
230                         upload = true
231                         c.debugf("DialSSH: remote checksum does not match")
232                 }
233         }
234
235         if upload {
236                 // Notify user that an upload is going to take place.
237                 c.events <- ConnEvent{
238                         Type: ConnEventUpload,
239                 }
240
241                 // Tell script we want to upload a new file.
242                 _, err = fmt.Fprintln(stdin, "upload")
243                 if err != nil {
244                         return err
245                 }
246                 // Get path to temporary file for upload.
247                 //
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')
254                 if err != nil {
255                         return err
256                 }
257                 path = strings.TrimSuffix(path, "\n")
258
259                 c.debugf("DialSSH: uploading new remote to %q at %q",
260                         c.sshRemote, path)
261
262                 cmd := exec.Command("ssh",
263                         append(append([]string{}, c.sshOpts...),
264                                 c.sshRemote,
265                                 fmt.Sprintf("cat > %q", path))...)
266                 cmd.Stdin = bytes.NewReader(helper)
267                 err = c.handleStderrAsEvents(cmd)
268                 if err != nil {
269                         return err
270                 }
271                 err = cmd.Run()
272                 if err != nil {
273                         return err
274                 }
275         }
276
277         // Tell script to continue and execute the remote helper
278         _, err = fmt.Fprintln(stdin, "")
279         if err != nil {
280                 return err
281         }
282
283         return nil
284 }
285
286 func connGetGoos(stdin io.Writer, stdout *bufio.Reader) (string, error) {
287         _, err := fmt.Fprintln(stdin, "uname")
288         if err != nil {
289                 return "", err
290         }
291         x, err := stdout.ReadString('\n')
292         if err != nil {
293                 return "", err
294         }
295         x = strings.TrimSpace(x)
296
297         // NOTE: Adapt helper uploading in dialSSH() when adding new systems
298         var goos string
299         switch x {
300         case "Linux":
301                 goos = "linux"
302         case "FreeBSD":
303                 goos = "freebsd"
304         case "OpenBSD":
305                 goos = "openbsd"
306         default:
307                 return "", fmt.Errorf("unsupported OS %q (`uname`)", x)
308         }
309         return goos, nil
310 }
311
312 func connGetGoarch(stdin io.Writer, stdout *bufio.Reader) (string, error) {
313         _, err := fmt.Fprintln(stdin, "uname -m")
314         if err != nil {
315                 return "", err
316         }
317         x, err := stdout.ReadString('\n')
318         if err != nil {
319                 return "", err
320         }
321         x = strings.TrimSpace(x)
322
323         // NOTE: Adapt cmd/safcm-remote/build.sh when adding new architectures
324         var goarch string
325         switch x {
326         case "x86_64", "amd64":
327                 goarch = "amd64"
328         case "armv7l":
329                 goarch = "armv7l"
330         default:
331                 return "", fmt.Errorf("unsupported arch %q (`uname -m`)", x)
332         }
333         return goarch, nil
334 }
335
336 func connGetUID(stdin io.Writer, stdout *bufio.Reader) (int, error) {
337         _, err := fmt.Fprintln(stdin, "id -u")
338         if err != nil {
339                 return -1, err
340         }
341         x, err := stdout.ReadString('\n')
342         if err != nil {
343                 return -1, err
344         }
345         x = strings.TrimSpace(x)
346
347         uid, err := strconv.Atoi(x)
348         if err != nil {
349                 return -1, fmt.Errorf("invalid UID %q (`id -u`)", x)
350         }
351         return uid, nil
352 }