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