move debbackup-ssh-wrap from dsa-misc to puppet
[mirror/dsa-puppet.git] / modules / postgres / files / backup_server / debbackup-ssh-wrap
1 #!/usr/bin/python
2
3 # Copyright (c) 2010 Peter Palfrader
4 #
5 # Permission is hereby granted, free of charge, to any person obtaining
6 # a copy of this software and associated documentation files (the
7 # "Software"), to deal in the Software without restriction, including
8 # without limitation the rights to use, copy, modify, merge, publish,
9 # distribute, sublicense, and/or sell copies of the Software, and to
10 # permit persons to whom the Software is furnished to do so, subject to
11 # the following conditions:
12 #
13 # The above copyright notice and this permission notice shall be
14 # included in all copies or substantial portions of the Software.
15 #
16 # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17 # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18 # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
19 # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
20 # LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
21 # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
22 # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
23
24 # an ssh command wrapper,
25 #
26 # stores a file supplied by the calling host.  We use this for postgres
27 # backungs, storing both base backups and WAL files.
28 #
29
30 import sys
31 import os
32 import optparse
33 import re
34 import subprocess
35 import syslog
36 import tempfile
37 import stat
38 import hashlib
39
40
41 basedir = '/srv/backups'
42 accepted_fileclasses = ['pg']
43
44 block_size = 4096
45
46 syslog.openlog(sys.argv[0], syslog.LOG_PID, syslog.LOG_DAEMON)
47
48 # Usage: debbackup-ssh-wrap [<options>] <calling host>
49 # via ssh orig command: <host> store-file <class> <name> <size> <sha512>
50 #                       <host> retrieve-file <class> <from_host> <name>
51
52 def info(m):
53     syslog.syslog(syslog.LOG_INFO, m)
54
55 def croak(m):
56     syslog.syslog(syslog.LOG_WARNING, m)
57     print >> sys.stderr, m
58     sys.exit(1)
59
60 def filename_sanity_check(fn):
61     if re.search("[^a-zA-Z0-9._-]", fn):
62         croak("Invalid characters encountered in '%s'."%(fn))
63
64 def get_classdir(file_class):
65     d = os.path.join(basedir, file_class)
66     if not os.path.exists(d):
67         croak("Classdir '%s' does not exist."%(d))
68     return d
69
70 def get_targetdir(classdir, host, create=False):
71     d = os.path.join(classdir, host)
72     if not os.path.exists(d):
73         if create:
74             info("Creating %s"%(d))
75             os.mkdir(d)
76         else:
77             croak("Targetdir '%s' does not exist."%(d))
78     return d
79
80 def sha512_for_file(fn):
81     d = hashlib.sha512()
82     f = open(fn)
83     while True:
84         data = f.read(block_size)
85         if not data: break
86         d.update(data)
87     f.close()
88     return d.hexdigest()
89
90
91 def store_file(host, remote_args):
92 # <class> <name> <size> <sha512>
93     if len(remote_args) != 4:
94         croak("Exactly four arguments expected for store-file.")
95         sys.exit(1)
96     (fileclass, filename, size, checksum) = remote_args
97
98     # check fileclass
99     if not fileclass in accepted_fileclasses:
100         croak("Invalid file class '%s'"%(fileclass))
101
102     # check filename
103     filename_sanity_check(filename)
104
105     # check and convert size
106     try:
107         size = int(size)
108     except ValueError:
109         croak("Invalid size argument '%s'"%(size))
110
111     # check checksum
112     if not re.match("^[a-f0-9]{128}$", checksum):
113         croak("Invalid checksum argument '%s'."%(checksum))
114
115     classdir = get_classdir(fileclass)
116     targetdir = get_targetdir(classdir, host, True)
117     target = os.path.join(targetdir, filename)
118
119     if os.path.exists(target):
120         checksum_on_disk = sha512_for_file(target)
121         size_on_disk = os.stat(target)[stat.ST_SIZE]
122         if size_on_disk == size and checksum_on_disk == checksum:
123             info("Target '%s' already exists, with same size and checksum (%d, %s)."%(target, size, checksum))
124             sys.exit(0)
125         else:
126             croak("Target '%s' already exists and has different size or checksum (%d vs %d; %s vs %s)."%(target, size_on_disk,size, checksum_on_disk, checksum))
127
128     tmp = tempfile.NamedTemporaryFile(dir=classdir, suffix=".%s.%s"%(host,filename))
129     info("Receiving remote %s from %s to %stmp (%s bytes)"%(filename, host, tmp.name, size))
130     running_size = 0
131     digest = hashlib.sha512()
132     while True:
133         buf = sys.stdin.read(block_size)
134         if not buf: break
135         digest.update(buf)
136         tmp.write(buf)
137
138         running_size += len(buf)
139         if running_size > size:
140             croak("Size mismatch")
141     tmp.flush()
142     file_size = os.stat(tmp.name)[stat.ST_SIZE]
143
144     if file_size != size:
145         croak("Size mismatch")
146     if file_size != running_size:
147         croak("Size mismatch. WTF.")
148     if checksum != digest.hexdigest():
149         croak("Checksum mismatch. WTF.")
150
151     try:
152         os.link(tmp.name, target)
153     except Exception, e:
154         croak("Failed at linking to target: %s"%(e))
155
156     tmp.close()
157     info("Successfully stored %s"%(target))
158
159
160 def retrieve_file(host, remote_args, allowed_reads):
161 # <class> <from_host> <name>
162     if len(remote_args) != 3:
163         croak("Exactly three arguments expected for retrieve-file.")
164         sys.exit(1)
165     (fileclass, from_host, filename) = remote_args
166
167     # check fileclass
168     if not fileclass in accepted_fileclasses:
169         croak("Invalid file class '%s'"%(fileclass))
170     # check filename
171     filename_sanity_check(filename)
172     # and host
173     filename_sanity_check(from_host)
174
175     classdir = get_classdir(fileclass)
176     sourcedir = get_targetdir(classdir, from_host)
177     source = os.path.join(sourcedir, filename)
178
179     abssource = os.path.abspath(source)
180     dirname = os.path.dirname(abssource)
181
182     if not dirname in allowed_reads:
183         croak("Host '%s' is not allowed to read from %s"%(host, dirname))
184
185     if not os.path.exists(abssource):
186         print "Format: 1"
187         print "Status: 404 not found"
188         info("Not sending %s to remote %s - file does not exist."%(abssource, host))
189         sys.exit(1)
190
191     file_size = os.stat(abssource)[stat.ST_SIZE]
192     sha512 = sha512_for_file(abssource)
193
194     info("Sending %s to remote %s (%s bytes)"%(abssource, host, file_size))
195
196     print "Format: 1"
197     print "Status: 200 OK"
198     print "Size: %d"%(file_size)
199     print "SHA-512: %s"%(sha512)
200     print
201     f = open(abssource)
202     while True:
203         data = f.read(block_size)
204         if not data: break
205         sys.stdout.write(data)
206     f.close()
207
208
209 parser = optparse.OptionParser()
210 parser.set_usage("%prog [<options>] <calling host>  (local usage)\n" +
211           "via ssh orig command: <host> store-file <class> <name> <size> <sha512>\n" +
212           "                      <host> retrieve-file <class> <from_host> <name>")
213 parser.add_option("-r", "--read-allow", dest="allowed_reads", metavar="DIR", action="append",
214   help="Allow host to read files in directory.")
215 (options, args) = parser.parse_args()
216
217 def ensure_args_not_empty(remote_args):
218     if len(remote_args) == 0:
219         croak("One more argument expected.")
220
221 if len(args) != 1:
222     parser.print_help()
223     sys.exit(1)
224
225 host = args.pop(0)
226
227 if not 'SSH_ORIGINAL_COMMAND' in os.environ:
228     print >> sys.stderr, "Did not find SSH_ORIGINAL_COMMAND in environment."
229     sys.exit(1)
230
231 remote_args = os.environ['SSH_ORIGINAL_COMMAND'].split()
232
233 ensure_args_not_empty(remote_args)
234 remote_supplied_hostname = remote_args.pop(0)
235 if remote_supplied_hostname != host:
236     croak("Hostname passed from remote does not match locally supplied hostname.")
237
238 ensure_args_not_empty(remote_args)
239 action = remote_args.pop(0)
240 info("Host %s called with action %s."%(host, action))
241 if action == "store-file":
242     store_file(host, remote_args)
243 elif action == "retrieve-file":
244     if options.allowed_reads is None:
245         croak("No directories from which read is allowed given on cmdline.")
246     retrieve_file(host, remote_args, options.allowed_reads)
247 else:
248     croak("Invalid operation '%s'"%(action))
249
250 # vim:set et:
251 # vim:set ts=4:
252 # vim:set shiftwidth=4: