]> ruderich.org/simon Gitweb - nsscash/nsscash.git/blob - file.go
nsscash: main_test: use configPath variable
[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         oldT := t
93         status, body, err := fetchIfModified(file.Url,
94                 file.Username, file.Password, file.CA, &t)
95         if err != nil {
96                 return err
97         }
98         if status == http.StatusNotModified {
99                 if oldT.IsZero() {
100                         return fmt.Errorf("status code 304 " +
101                                 "but did not send If-Modified-Since")
102                 }
103                 log.Printf("%q -> %q: not modified", file.Url, file.Path)
104                 return nil
105         }
106         if status != http.StatusOK {
107                 return fmt.Errorf("status code %v", status)
108         }
109         state.LastModified[file.Url] = t
110
111         if file.Type == FileTypePlain {
112                 if len(body) == 0 {
113                         return fmt.Errorf("refusing to use empty response")
114                 }
115                 file.body = body
116
117         } else if file.Type == FileTypePasswd {
118                 pws, err := ParsePasswds(bytes.NewReader(body))
119                 if err != nil {
120                         return err
121                 }
122                 // Safety check: having no users can be very dangerous, don't
123                 // permit it
124                 if len(pws) == 0 {
125                         return fmt.Errorf("refusing to use empty passwd file")
126                 }
127
128                 var x bytes.Buffer
129                 err = SerializePasswds(&x, pws)
130                 if err != nil {
131                         return err
132                 }
133                 file.body = x.Bytes()
134
135         } else if file.Type == FileTypeGroup {
136                 grs, err := ParseGroups(bytes.NewReader(body))
137                 if err != nil {
138                         return err
139                 }
140                 if len(grs) == 0 {
141                         return fmt.Errorf("refusing to use empty group file")
142                 }
143
144                 var x bytes.Buffer
145                 err = SerializeGroups(&x, grs)
146                 if err != nil {
147                         return err
148                 }
149                 file.body = x.Bytes()
150
151         } else {
152                 return fmt.Errorf("unsupported file type %v", file.Type)
153         }
154
155         state.Checksum[file.Url] = checksumBytes(file.body)
156         return nil
157 }
158
159 func deployFile(file *File) error {
160         log.Printf("%q -> %q: updating file", file.Url, file.Path)
161
162         // Safety check
163         if len(file.body) == 0 {
164                 return fmt.Errorf("refusing to write empty file")
165         }
166
167         // Write the file in an atomic fashion by creating a temporary file
168         // and renaming it over the target file
169
170         dir := filepath.Dir(file.Path)
171         name := filepath.Base(file.Path)
172
173         f, err := ioutil.TempFile(dir, "tmp-"+name+"-")
174         if err != nil {
175                 return err
176         }
177         defer os.Remove(f.Name())
178         defer f.Close()
179
180         // Apply permissions/user/group from the target file but remove the
181         // write permissions to discourage manual modifications, use Stat
182         // instead of Lstat as only the target's permissions are relevant
183         stat, err := os.Stat(file.Path)
184         if err != nil {
185                 // We do not create the path if it doesn't exist, because we
186                 // do not know the proper permissions
187                 return errors.Wrapf(err, "file.path %q must exist", file.Path)
188         }
189         err = f.Chmod(stat.Mode() & ^os.FileMode(0222)) // remove write perms
190         if err != nil {
191                 return err
192         }
193         // TODO: support more systems
194         sys, ok := stat.Sys().(*syscall.Stat_t)
195         if !ok {
196                 return fmt.Errorf("unsupported FileInfo.Sys()")
197         }
198         err = f.Chown(int(sys.Uid), int(sys.Gid))
199         if err != nil {
200                 return err
201         }
202
203         _, err = f.Write(file.body)
204         if err != nil {
205                 return err
206         }
207         err = f.Sync()
208         if err != nil {
209                 return err
210         }
211         return os.Rename(f.Name(), file.Path)
212 }