1 // Download and write files atomically to the file system
3 // Copyright (C) 2019-2020 Simon Ruderich
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.
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.
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/>.
33 "github.com/google/renameio"
34 "github.com/pkg/errors"
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
43 for i, f := range cfg.Files {
44 err := fetchFile(&cfg.Files[i], state)
46 return errors.Wrapf(err, "%q (%v)", f.Url, f.Type)
50 for i, f := range cfg.Files {
56 err := deployFile(&cfg.Files[i])
58 return errors.Wrapf(err, "%q (%v)", f.Url, f.Type)
65 func checksumFile(file *File) (string, error) {
66 x, err := ioutil.ReadFile(file.Path)
70 return checksumBytes(x), nil
73 func checksumBytes(x []byte) string {
76 return hex.EncodeToString(h.Sum(nil))
79 func fetchFile(file *File, state *State) error {
80 t := state.LastModified[file.Url]
82 hash, err := checksumFile(file)
84 // See below in deployFile() for the reason
85 return errors.Wrapf(err, "file.path %q must exist", file.Path)
87 if hash != state.Checksum[file.Url] {
88 log.Printf("%q -> %q: hash has changed", file.Url, file.Path)
90 t = zero // force download
94 status, body, err := fetchIfModified(file.Url,
95 file.Username, file.Password, file.CA, &t)
99 if status == http.StatusNotModified {
101 return fmt.Errorf("status code 304 " +
102 "but did not send If-Modified-Since")
104 log.Printf("%q -> %q: not modified", file.Url, file.Path)
107 if status != http.StatusOK {
108 return fmt.Errorf("status code %v", status)
110 state.LastModified[file.Url] = t
112 if file.Type == FileTypePlain {
114 return fmt.Errorf("refusing to use empty response")
118 } else if file.Type == FileTypePasswd {
119 pws, err := ParsePasswds(bytes.NewReader(body))
123 // Safety check: having no users can be very dangerous, don't
126 return fmt.Errorf("refusing to use empty passwd file")
130 err = SerializePasswds(&x, pws)
134 file.body = x.Bytes()
136 } else if file.Type == FileTypeGroup {
137 grs, err := ParseGroups(bytes.NewReader(body))
142 return fmt.Errorf("refusing to use empty group file")
146 err = SerializeGroups(&x, grs)
150 file.body = x.Bytes()
153 return fmt.Errorf("unsupported file type %v", file.Type)
156 state.Checksum[file.Url] = checksumBytes(file.body)
160 func deployFile(file *File) error {
161 log.Printf("%q -> %q: updating file", file.Url, file.Path)
164 if len(file.body) == 0 {
165 return fmt.Errorf("refusing to write empty file")
168 f, err := renameio.TempFile(filepath.Dir(file.Path), file.Path)
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)
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)
183 err = f.Chmod(stat.Mode() & ^os.FileMode(0222)) // remove write perms
187 // TODO: support more systems
188 sys, ok := stat.Sys().(*syscall.Stat_t)
190 return fmt.Errorf("unsupported FileInfo.Sys()")
192 err = f.Chown(int(sys.Uid), int(sys.Gid))
197 _, err = f.Write(file.body)
201 return f.CloseAtomicallyReplace()