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