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