]> ruderich.org/simon Gitweb - nsscash/nsscash.git/blob - file.go
.github: update upstream actions to latest version
[nsscash/nsscash.git] / file.go
1 // Download and write files atomically to the file system
2
3 // Copyright (C) 2019-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 Affero 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 Affero General Public License for more details.
14 //
15 // You should have received a copy of the GNU Affero General Public License
16 // along with this program.  If not, see <https://www.gnu.org/licenses/>.
17
18 package main
19
20 import (
21         "bytes"
22         "crypto/sha512"
23         "encoding/hex"
24         "fmt"
25         "io/ioutil"
26         "log"
27         "net/http"
28         "os"
29         "path/filepath"
30         "syscall"
31         "time"
32
33         "github.com/google/renameio"
34         "github.com/pkg/errors"
35 )
36
37 func handleFiles(cfg *Config, state *State) error {
38         // Split into fetch and deploy phase to prevent updates of only some
39         // files which might lead to inconsistent state; obviously this won't
40         // work during the deploy phase, but it helps if the web server fails
41         // to deliver some files
42
43         for i, f := range cfg.Files {
44                 err := fetchFile(&cfg.Files[i], state)
45                 if err != nil {
46                         return errors.Wrapf(err, "%q (%v)", f.Url, f.Type)
47                 }
48         }
49
50         for i, f := range cfg.Files {
51                 // No update required
52                 if f.body == nil {
53                         continue
54                 }
55
56                 err := deployFile(&cfg.Files[i])
57                 if err != nil {
58                         return errors.Wrapf(err, "%q (%v)", f.Url, f.Type)
59                 }
60         }
61
62         return nil
63 }
64
65 func checksumFile(file *File) (string, error) {
66         x, err := ioutil.ReadFile(file.Path)
67         if err != nil {
68                 return "", err
69         }
70         return checksumBytes(x), nil
71 }
72
73 func checksumBytes(x []byte) string {
74         h := sha512.New()
75         h.Write(x)
76         return hex.EncodeToString(h.Sum(nil))
77 }
78
79 func fetchFile(file *File, state *State) error {
80         t := state.LastModified[file.Url]
81
82         hash, err := checksumFile(file)
83         if err != nil {
84                 // See below in deployFile() for the reason
85                 return errors.Wrapf(err, "file.path %q must exist", file.Path)
86         }
87         if hash != state.Checksum[file.Url] {
88                 log.Printf("%q -> %q: hash has changed", file.Url, file.Path)
89                 var zero time.Time
90                 t = zero // force download
91         }
92
93         oldT := t
94         status, body, err := fetchIfModified(file.Url,
95                 file.Username, file.Password, file.CA, &t)
96         if err != nil {
97                 return err
98         }
99         if status == http.StatusNotModified {
100                 if oldT.IsZero() {
101                         return fmt.Errorf("status code 304 " +
102                                 "but did not send If-Modified-Since")
103                 }
104                 log.Printf("%q -> %q: not modified", file.Url, file.Path)
105                 return nil
106         }
107         if status != http.StatusOK {
108                 return fmt.Errorf("status code %v", status)
109         }
110         state.LastModified[file.Url] = t
111
112         if file.Type == FileTypePlain {
113                 if len(body) == 0 {
114                         return fmt.Errorf("refusing to use empty response")
115                 }
116                 file.body = body
117
118         } else if file.Type == FileTypePasswd {
119                 pws, err := ParsePasswds(bytes.NewReader(body))
120                 if err != nil {
121                         return err
122                 }
123                 // Safety check: having no users can be very dangerous, don't
124                 // permit it
125                 if len(pws) == 0 {
126                         return fmt.Errorf("refusing to use empty passwd file")
127                 }
128
129                 var x bytes.Buffer
130                 err = SerializePasswds(&x, pws)
131                 if err != nil {
132                         return err
133                 }
134                 file.body = x.Bytes()
135
136         } else if file.Type == FileTypeGroup {
137                 grs, err := ParseGroups(bytes.NewReader(body))
138                 if err != nil {
139                         return err
140                 }
141                 if len(grs) == 0 {
142                         return fmt.Errorf("refusing to use empty group file")
143                 }
144
145                 var x bytes.Buffer
146                 err = SerializeGroups(&x, grs)
147                 if err != nil {
148                         return err
149                 }
150                 file.body = x.Bytes()
151
152         } else {
153                 return fmt.Errorf("unsupported file type %v", file.Type)
154         }
155
156         state.Checksum[file.Url] = checksumBytes(file.body)
157         return nil
158 }
159
160 func deployFile(file *File) error {
161         log.Printf("%q -> %q: updating file", file.Url, file.Path)
162
163         // Safety check
164         if len(file.body) == 0 {
165                 return fmt.Errorf("refusing to write empty file")
166         }
167
168         f, err := renameio.TempFile(filepath.Dir(file.Path), file.Path)
169         if err != nil {
170                 return err
171         }
172         defer f.Cleanup()
173
174         // Apply permissions/user/group from the target file but remove the
175         // write permissions to discourage manual modifications, use Stat
176         // instead of Lstat as only the target's permissions are relevant
177         stat, err := os.Stat(file.Path)
178         if err != nil {
179                 // We do not create the path if it doesn't exist, because we
180                 // do not know the proper permissions
181                 return errors.Wrapf(err, "file.path %q must exist", file.Path)
182         }
183         err = f.Chmod(stat.Mode() & ^os.FileMode(0222)) // remove write perms
184         if err != nil {
185                 return err
186         }
187         // TODO: support more systems
188         sys, ok := stat.Sys().(*syscall.Stat_t)
189         if !ok {
190                 return fmt.Errorf("unsupported FileInfo.Sys()")
191         }
192         err = f.Chown(int(sys.Uid), int(sys.Gid))
193         if err != nil {
194                 return err
195         }
196
197         _, err = f.Write(file.body)
198         if err != nil {
199                 return err
200         }
201         err = f.CloseAtomicallyReplace()
202         if err != nil {
203                 return err
204         }
205         return syncPath(filepath.Dir(file.Path))
206 }