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