]> ruderich.org/simon Gitweb - safcm/safcm.git/blob - remote/sync/files.go
24c59ce1d0bac7f560249f718677922a929dfa25
[safcm/safcm.git] / remote / sync / files.go
1 // MsgSyncReq: copy files to the remote host
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 // +build !windows
19
20 package sync
21
22 import (
23         "bytes"
24         "fmt"
25         "io"
26         "io/fs"
27         "math/rand"
28         "net/http"
29         "os"
30         "os/user"
31         slashpath "path"
32         "path/filepath"
33         "sort"
34         "strconv"
35         "strings"
36         "time"
37
38         "github.com/ianbruene/go-difflib/difflib"
39         "golang.org/x/sys/unix"
40
41         "ruderich.org/simon/safcm"
42 )
43
44 // NOTE: Don't use plain os.Open, os.OpenFile, os.Remove or any other function
45 // which uses absolute paths. These are vulnerable to symlink attacks. Always
46 // use *at syscalls. See below for details.
47
48 // openReadonlyFlags are flags for open* syscalls to safely read a file or
49 // directory.
50 //
51 // O_NOFOLLOW prevents symlink attacks
52 // O_NONBLOCK is necessary to prevent blocking on FIFOs
53 const openReadonlyFlags = unix.O_RDONLY | unix.O_NOFOLLOW | unix.O_NONBLOCK
54
55 func (s *Sync) syncFiles() error {
56         // To create random file names for symlinks
57         rand.Seed(time.Now().UnixNano())
58
59         // Sort for deterministic order and so parent directories are present
60         // when files in them are created
61         var files []*safcm.File
62         for _, x := range s.req.Files {
63                 files = append(files, x)
64         }
65         sort.Slice(files, func(i, j int) bool {
66                 return files[i].Path < files[j].Path
67         })
68
69         for _, x := range files {
70                 var changed bool
71                 err := s.syncFile(x, &changed)
72                 if err != nil {
73                         return fmt.Errorf("%q: %v", x.Path, err)
74                 }
75                 if changed {
76                         s.queueTriggers(x)
77                 }
78         }
79
80         return nil
81 }
82
83 func (s *Sync) syncFile(file *safcm.File, changed *bool) error {
84         // The general strategy is "update by rename": If any property of a
85         // file changes it will be written to a temporary file and then
86         // renamed "over" the original file. This is simple and prevents race
87         // conditions where the file is partially readable while changes to
88         // permissions or owner/group are applied. However, this strategy does
89         // not work for directories which must be removed first (was
90         // directory), must remove the existing file (will be directory) or
91         // must be directly modified (changed permissions or owner). In the
92         // first two cases the old path is removed. In the last the directory
93         // is modified (carefully) in place.
94         //
95         // The implementation is careful not to follow any symlinks to prevent
96         // possible race conditions which can be exploited and are especially
97         // dangerous when running with elevated privileges (which will most
98         // likely be the case). This includes not using absolute paths in
99         // syscalls to prevent symlink attacks when a directory is writable by
100         // other users (e.g. when syncing a file to /home/user/dir/file the
101         // user could create dir as symlink to another directory and file
102         // would be written there). To prevent this *at syscalls are used and
103         // all symlinks in the path are rejected. This still permits the user
104         // to move dir during the sync but only to places which are writable
105         // by the user which cannot be prevented.
106
107         err := s.fileResolveIds(file)
108         if err != nil {
109                 return err
110         }
111
112         change := safcm.FileChange{
113                 Path: file.Path,
114                 New: safcm.FileChangeInfo{
115                         Mode:  file.Mode,
116                         User:  file.User,
117                         Uid:   file.Uid,
118                         Group: file.Group,
119                         Gid:   file.Gid,
120                 },
121         }
122
123         debugf := func(format string, a ...interface{}) {
124                 s.log.Debugf("files: %q (%s): %s",
125                         file.Path, file.OrigGroup, fmt.Sprintf(format, a...))
126         }
127         verbosef := func(format string, a ...interface{}) {
128                 s.log.Verbosef("files: %q (%s): %s",
129                         file.Path, file.OrigGroup, fmt.Sprintf(format, a...))
130         }
131
132         parentFd, baseName, err := OpenParentDirectoryNoSymlinks(file.Path)
133         if err != nil {
134                 return err
135         }
136         defer unix.Close(parentFd)
137
138         var oldStat unix.Stat_t
139 reopen:
140         oldFh, err := OpenAtNoFollow(parentFd, baseName)
141         if err != nil {
142                 if err == unix.ELOOP || err == unix.EMLINK {
143                         // ELOOP from symbolic link, this is fine
144                         err := unix.Fstatat(parentFd, baseName, &oldStat,
145                                 unix.AT_SYMLINK_NOFOLLOW)
146                         if err != nil {
147                                 return err
148                         }
149                         if oldStat.Mode&unix.S_IFMT != unix.S_IFLNK {
150                                 debugf("type changed from symlink, retrying")
151                                 goto reopen
152                         }
153                 } else if os.IsNotExist(err) {
154                         change.Created = true
155                         debugf("will create")
156                 } else {
157                         return err
158                 }
159         } else {
160                 defer oldFh.Close()
161
162                 err := unix.Fstat(int(oldFh.Fd()), &oldStat)
163                 if err != nil {
164                         return err
165                 }
166         }
167
168         var oldData []byte
169         var changeType, changePerm, changeUserOrGroup, changeData bool
170         if !change.Created {
171                 // Manually convert to FileMode; from src/os/stat_linux.go in
172                 // Go's sources (stat_*.go for other UNIX systems are
173                 // identical, except for stat_darwin.go which has an extra
174                 // S_IFWHT)
175                 mode := fs.FileMode(oldStat.Mode & 0777)
176                 switch oldStat.Mode & unix.S_IFMT {
177                 case unix.S_IFBLK:
178                         mode |= fs.ModeDevice
179                 case unix.S_IFCHR:
180                         mode |= fs.ModeDevice | fs.ModeCharDevice
181                 case unix.S_IFDIR:
182                         mode |= fs.ModeDir
183                 case unix.S_IFIFO:
184                         mode |= fs.ModeNamedPipe
185                 case unix.S_IFLNK:
186                         mode |= fs.ModeSymlink
187                 case unix.S_IFREG:
188                         // nothing to do
189                 case unix.S_IFSOCK:
190                         mode |= fs.ModeSocket
191                 // Guard against unknown file types (e.g. on darwin); not in
192                 // stat_*.go
193                 default:
194                         return fmt.Errorf("unexpected file mode %v",
195                                 oldStat.Mode&unix.S_IFMT)
196                 }
197                 if oldStat.Mode&unix.S_ISGID != 0 {
198                         mode |= fs.ModeSetgid
199                 }
200                 if oldStat.Mode&unix.S_ISUID != 0 {
201                         mode |= fs.ModeSetuid
202                 }
203                 if oldStat.Mode&unix.S_ISVTX != 0 {
204                         mode |= fs.ModeSticky
205                 }
206
207                 // Compare permissions
208                 change.Old.Mode = mode
209                 if change.Old.Mode.Type() == fs.ModeSymlink {
210                         // Some BSD systems permit changing permissions of
211                         // symlinks but ignore them on traversal. To keep it
212                         // simple we don't support that and always use 0777
213                         // for symlink permissions (the value on GNU/Linux).
214                         //
215                         // TODO: Add proper support for symlinks on BSD
216                         change.Old.Mode |= 0777
217                 }
218                 if change.Old.Mode != file.Mode {
219                         if change.Old.Mode.Type() != file.Mode.Type() {
220                                 changeType = true
221                                 debugf("type differs %s -> %s",
222                                         change.Old.Mode.Type(),
223                                         file.Mode.Type())
224                         } else {
225                                 // Be careful with .Perm() which does not
226                                 // contain the setuid/setgid/sticky bits!
227                                 changePerm = true
228                                 debugf("permission differs %s -> %s",
229                                         change.Old.Mode, file.Mode)
230                         }
231                 }
232
233                 // Compare user/group
234                 change.Old.Uid = int(oldStat.Uid)
235                 change.Old.Gid = int(oldStat.Gid)
236                 if change.Old.Uid != file.Uid || change.Old.Gid != file.Gid {
237                         changeUserOrGroup = true
238                         debugf("uid/gid differs %d/%d -> %d/%d",
239                                 change.Old.Uid, change.Old.Gid,
240                                 file.Uid, file.Gid)
241                 }
242                 u, g, err := resolveIdsToNames(change.Old.Uid, change.Old.Gid)
243                 // Errors are not relevant as this is only used to report the
244                 // change. If the user/group no longer exits only the ids will
245                 // be reported.
246                 if err == nil {
247                         change.Old.User = u
248                         change.Old.Group = g
249                 }
250
251                 // Compare file content (if possible)
252                 switch change.Old.Mode.Type() {
253                 case 0: // regular file
254                         x, err := io.ReadAll(oldFh)
255                         if err != nil {
256                                 return fmt.Errorf("reading old content: %v",
257                                         err)
258                         }
259                         oldData = x
260                 case fs.ModeSymlink:
261                         buf := make([]byte, unix.PathMax)
262                         n, err := unix.Readlinkat(parentFd, baseName, buf)
263                         if err != nil {
264                                 return fmt.Errorf("reading old content: %v",
265                                         err)
266                         }
267                         oldData = buf[:n]
268                 }
269                 if !changeType && file.Mode.Type() != fs.ModeDir {
270                         if !bytes.Equal(oldData, file.Data) {
271                                 changeData = true
272                                 debugf("content differs")
273                         }
274                 }
275         }
276
277         // No changes
278         if !change.Created && !changeType &&
279                 !changePerm && !changeUserOrGroup &&
280                 !changeData {
281                 debugf("unchanged")
282                 return nil
283         }
284         *changed = true
285
286         // Don't show a diff with the full content for newly created files or
287         // on type changes. This is just noise for the user as the new file
288         // content is obvious. But we always want to see a diff when files are
289         // replaced because this destroys data.
290         if !change.Created &&
291                 (change.Old.Mode.Type() == 0 ||
292                         change.Old.Mode.Type() == fs.ModeSymlink) {
293                 change.DataDiff, err = diffData(oldData, file.Data)
294                 if err != nil {
295                         return err
296                 }
297         }
298
299         // Add change here so it is stored even when applying it fails. This
300         // way the user knows exactly what was attempted.
301         s.resp.FileChanges = append(s.resp.FileChanges, change)
302
303         if change.Created {
304                 verbosef("creating")
305         } else {
306                 verbosef("updating")
307         }
308
309         if s.req.DryRun {
310                 debugf("dry-run, skipping changes")
311                 return nil
312         }
313
314         // We cannot rename over directories and vice versa
315         if changeType && (change.Old.Mode.IsDir() || file.Mode.IsDir()) {
316                 debugf("removing (due to type change)")
317                 // In the past os.RemoveAll() was used here. However, this is
318                 // difficult to implement manually with *at syscalls. To keep
319                 // it simple only permit removing files and empty directories
320                 // here. This also has the bonus of preventing data loss when
321                 // (accidentally) replacing a directory tree with a file.
322                 const msg = "will not replace non-empty directory, " +
323                         "please remove manually"
324                 err := unix.Unlinkat(parentFd, baseName, 0)
325                 if err != nil && !os.IsNotExist(err) {
326                         err2 := unix.Unlinkat(parentFd, baseName,
327                                 AT_REMOVEDIR)
328                         if err2 != nil && !os.IsNotExist(err2) {
329                                 // See src/os/file_unix.go in Go's sources
330                                 if err2 == unix.ENOTDIR {
331                                         return err
332                                 } else if err2 == unix.ENOTEMPTY {
333                                         return fmt.Errorf(msg)
334                                 } else {
335                                         return err2
336                                 }
337                         }
338                 }
339         }
340
341         // Directory: create new directory, also type change to directory
342         if file.Mode.IsDir() && (change.Created || changeType) {
343                 debugf("creating directory")
344                 err := unix.Mkdirat(parentFd, baseName, 0700)
345                 if err != nil {
346                         return err
347                 }
348                 // We must be careful not to chmod arbitrary files. If the
349                 // target directory is writable then it might have changed to
350                 // a symlink at this point. There's no lchmod and fchmodat is
351                 // incomplete so open the directory.
352                 debugf("chmodding %s", file.Mode)
353                 dh, err := OpenAtNoFollow(parentFd, baseName)
354                 if err != nil {
355                         return err
356                 }
357                 defer dh.Close()
358
359                 err = dh.Chmod(file.Mode)
360                 if err != nil {
361                         return err
362                 }
363                 // Less restrictive access is not relevant here because there
364                 // are no files present yet.
365                 debugf("chowning %d/%d", file.Uid, file.Gid)
366                 err = dh.Chown(file.Uid, file.Gid)
367                 if err != nil {
368                         return err
369                 }
370                 return nil
371         }
372         // Directory: changed permission or user/group
373         if file.Mode.IsDir() {
374                 // We don't know if the new permission or if the new
375                 // user/group is more restrictive (e.g. root:root 0750 ->
376                 // user:group 0700; applying group first gives group
377                 // unexpected access). To prevent a short window where the
378                 // access might be too lax we temporarily deny all access.
379                 if changePerm && changeUserOrGroup {
380                         // Only drop group and other permission because user
381                         // has access anyway (either before or after the
382                         // change). This also prevents temporary errors during
383                         // the error when the user tries to access this
384                         // directory (access for the group will fail though).
385                         mode := change.Old.Mode & fs.ModePerm & 0700
386                         debugf("chmodding %#o (temporary)", mode)
387                         err := oldFh.Chmod(mode)
388                         if err != nil {
389                                 return err
390                         }
391                 }
392                 if changeUserOrGroup {
393                         debugf("chowning %d/%d", file.Uid, file.Gid)
394                         err := oldFh.Chown(file.Uid, file.Gid)
395                         if err != nil {
396                                 return err
397                         }
398                 }
399                 if changePerm {
400                         debugf("chmodding %s", file.Mode)
401                         err := oldFh.Chmod(file.Mode)
402                         if err != nil {
403                                 return err
404                         }
405                 }
406                 return nil
407         }
408
409         dir := slashpath.Dir(file.Path) // only used in debug messages
410         // Create hidden file which should be ignored by most other tools and
411         // thus not affect anything during creation
412         tmpBase := "." + baseName
413
414         switch file.Mode.Type() {
415         case 0: // regular file
416                 debugf("creating temporary file %q",
417                         slashpath.Join(dir, tmpBase+"*"))
418                 x, err := WriteTempAt(parentFd, tmpBase, file.Data,
419                         file.Uid, file.Gid, file.Mode)
420                 if err != nil {
421                         return err
422                 }
423                 tmpBase = x
424
425         case fs.ModeSymlink:
426                 i := 0
427         retry:
428                 x := tmpBase + strconv.Itoa(rand.Int())
429                 debugf("creating temporary symlink %q",
430                         slashpath.Join(dir, x))
431                 err := unix.Symlinkat(string(file.Data), parentFd, x)
432                 if err != nil {
433                         if os.IsExist(err) && i < 10000 {
434                                 i++
435                                 goto retry
436                         }
437                         return err
438                 }
439                 tmpBase = x
440
441                 err = unix.Fchownat(parentFd, tmpBase, file.Uid, file.Gid,
442                         unix.AT_SYMLINK_NOFOLLOW)
443                 if err != nil {
444                         unix.Unlinkat(parentFd, tmpBase, 0)
445                         return err
446                 }
447                 // Permissions are irrelevant for symlinks (on most systems)
448
449         default:
450                 panic(fmt.Sprintf("invalid file type %s", file.Mode))
451         }
452
453         debugf("renaming %q", slashpath.Join(dir, tmpBase))
454         err = unix.Renameat(parentFd, tmpBase, parentFd, baseName)
455         if err != nil {
456                 unix.Unlinkat(parentFd, tmpBase, 0)
457                 return err
458         }
459         // To guarantee durability fsync must be called on a parent directory
460         // after adding, renaming or removing files therein.
461 //
462 // Calling sync on the files itself is not enough according to POSIX; man 2
463 // fsync: "Calling fsync() does not necessarily ensure that the entry in the
464 // directory containing the file has also reached disk. For that an explicit
465 // fsync() on a file descriptor for the directory is also needed."
466         err = unix.Fsync(parentFd)
467         if err != nil {
468                 return err
469         }
470
471         return nil
472 }
473
474 func (s *Sync) fileResolveIds(file *safcm.File) error {
475         if file.User != "" && file.Uid != -1 {
476                 return fmt.Errorf("cannot set both User (%q) and Uid (%d)",
477                         file.User, file.Uid)
478         }
479         if file.Group != "" && file.Gid != -1 {
480                 return fmt.Errorf("cannot set both Group (%q) and Gid (%d)",
481                         file.Group, file.Gid)
482         }
483
484         if file.User == "" && file.Uid == -1 {
485                 file.User = s.defaultUser
486         }
487         if file.User != "" {
488                 x, err := user.Lookup(file.User)
489                 if err != nil {
490                         return err
491                 }
492                 id, err := strconv.Atoi(x.Uid)
493                 if err != nil {
494                         return err
495                 }
496                 file.Uid = id
497         }
498
499         if file.Group == "" && file.Gid == -1 {
500                 file.Group = s.defaultGroup
501         }
502         if file.Group != "" {
503                 x, err := user.LookupGroup(file.Group)
504                 if err != nil {
505                         return err
506                 }
507                 id, err := strconv.Atoi(x.Gid)
508                 if err != nil {
509                         return err
510                 }
511                 file.Gid = id
512         }
513
514         return nil
515 }
516
517 // OpenParentDirectoryNoSymlinks opens the dirname of path without following
518 // any symlinks and returns a file descriptor to it and the basename of path.
519 // To prevent symlink attacks in earlier path components when these are
520 // writable by other users it starts at the root (or current directory for
521 // relative paths) and uses openat (with O_NOFOLLOW) for each path component.
522 // If a symlink is encountered it returns an error. However, it's impossible
523 // to guarantee that the returned descriptor refers to the same location as
524 // given in path because users with write access can rename path components.
525 // But this is not required to prevent the mentioned attacks.
526 func OpenParentDirectoryNoSymlinks(path string) (int, string, error) {
527         // Slash separated paths are used for the configuration
528         parts := strings.Split(path, "/")
529
530         var dir string
531         if path == "/" {
532                 // Root: use root itself as base name because root is the
533                 // parent of itself
534                 dir = "/"
535                 parts = []string{"/"}
536         } else if parts[0] == "" {
537                 // Absolute path
538                 dir = "/"
539                 parts = parts[1:]
540         } else if path == "." {
541                 // Current directory: open parent directory and use current
542                 // directory name as base name
543                 wd, err := os.Getwd()
544                 if err != nil {
545                         return -1, "", fmt.Errorf(
546                                 "failed to get working directory: %w", err)
547                 }
548                 dir = ".."
549                 parts = []string{filepath.Base(wd)}
550         } else if parts[0] != "." {
551                 // Relative path: start at the current directory
552                 dir = "."
553         }
554
555         dirFd, err := unix.Openat(unix.AT_FDCWD, dir, openReadonlyFlags, 0)
556         if err != nil {
557                 return -1, "", err
558         }
559         // Walk path one directory at a time to ensure there are no symlinks
560         // in the path. This prevents users with write access to change the
561         // path to point to arbitrary locations. O_NOFOLLOW when opening the
562         // path is not enough as only the last path component is checked.
563         for i, name := range parts[:len(parts)-1] {
564                 fd, err := unix.Openat(dirFd, name, openReadonlyFlags, 0)
565                 if err != nil {
566                         unix.Close(dirFd)
567                         if err == unix.ELOOP || err == unix.EMLINK {
568                                 x := filepath.Join(append([]string{dir},
569                                         parts[:i+1]...)...)
570                                 return -1, "", fmt.Errorf(
571                                         "symlink not permitted in path: %q",
572                                         x)
573                         }
574                         return -1, "", err
575                 }
576                 unix.Close(dirFd)
577                 dirFd = fd
578         }
579
580         return dirFd, parts[len(parts)-1], nil
581 }
582
583 func resolveIdsToNames(uid, gid int) (string, string, error) {
584         u, err := user.LookupId(strconv.Itoa(uid))
585         if err != nil {
586                 return "", "", err
587         }
588         g, err := user.LookupGroupId(strconv.Itoa(gid))
589         if err != nil {
590                 return "", "", err
591         }
592         return u.Username, g.Name, nil
593 }
594
595 func diffData(oldData []byte, newData []byte) (string, error) {
596         oldBin := !strings.HasPrefix(http.DetectContentType(oldData), "text/")
597         newBin := !strings.HasPrefix(http.DetectContentType(newData), "text/")
598         if oldBin && newBin {
599                 return fmt.Sprintf("Binary files differ (%d -> %d bytes), "+
600                         "cannot show diff", len(oldData), len(newData)), nil
601         }
602         if oldBin {
603                 oldData = []byte(fmt.Sprintf("<binary content, %d bytes>\n",
604                         len(oldData)))
605         }
606         if newBin {
607                 newData = []byte(fmt.Sprintf("<binary content, %d bytes>\n",
608                         len(newData)))
609         }
610
611         // TODO: difflib shows empty context lines at the end of the file
612         // which should not be there
613         // TODO: difflib has issues with missing newlines in either side
614         result, err := difflib.GetUnifiedDiffString(difflib.LineDiffParams{
615                 A:       difflib.SplitLines(string(oldData)),
616                 B:       difflib.SplitLines(string(newData)),
617                 Context: 3,
618         })
619         if err != nil {
620                 return "", err
621         }
622         return result, nil
623 }
624
625 func OpenFileNoSymlinks(path string) (*os.File, error) {
626         parentFd, baseName, err := OpenParentDirectoryNoSymlinks(path)
627         if err != nil {
628                 return nil, err
629         }
630         defer unix.Close(parentFd)
631         return OpenAtNoFollow(parentFd, baseName)
632 }
633
634 func OpenAtNoFollow(dirFd int, base string) (*os.File, error) {
635         fd, err := unix.Openat(dirFd, base, openReadonlyFlags, 0)
636         if err != nil {
637                 return nil, err
638         }
639         return os.NewFile(uintptr(fd), ""), nil
640 }
641
642 func WriteTempAt(dirFd int, base string, data []byte, uid, gid int,
643         mode fs.FileMode) (string, error) {
644
645         fh, tmpBase, err := createTempAt(dirFd, base)
646         if err != nil {
647                 return "", err
648         }
649
650         _, err = fh.Write(data)
651         if err != nil {
652                 fh.Close()
653                 unix.Unlinkat(dirFd, tmpBase, 0)
654                 return "", err
655         }
656         // createTempAt() creates the file with 0600
657         err = fh.Chown(uid, gid)
658         if err != nil {
659                 fh.Close()
660                 unix.Unlinkat(dirFd, tmpBase, 0)
661                 return "", err
662         }
663         err = fh.Chmod(mode)
664         if err != nil {
665                 fh.Close()
666                 unix.Unlinkat(dirFd, tmpBase, 0)
667                 return "", err
668         }
669         err = fh.Sync()
670         if err != nil {
671                 fh.Close()
672                 unix.Unlinkat(dirFd, tmpBase, 0)
673                 return "", err
674         }
675         err = fh.Close()
676         if err != nil {
677                 unix.Unlinkat(dirFd, tmpBase, 0)
678                 return "", err
679         }
680
681         return tmpBase, nil
682 }
683
684 // createTempAt works similar to os.CreateTemp but uses unix.Openat to create
685 // the temporary file.
686 func createTempAt(dirFd int, base string) (*os.File, string, error) {
687         var tmpBase string
688
689         i := 0
690 retry:
691         tmpBase = base + strconv.Itoa(rand.Int())
692
693         fd, err := unix.Openat(dirFd, tmpBase,
694                 unix.O_RDWR|unix.O_CREAT|unix.O_EXCL, 0600)
695         if err != nil {
696                 if os.IsExist(err) && i < 10000 {
697                         i++
698                         goto retry
699                 }
700                 return nil, "", err
701         }
702
703         return os.NewFile(uintptr(fd), ""), tmpBase, nil
704 }