3 # Copyright (c) 2010 Peter Palfrader
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:
13 # The above copyright notice and this permission notice shall be
14 # included in all copies or substantial portions of the Software.
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.
24 # an ssh command wrapper,
26 # stores a file supplied by the calling host. We use this for postgres
27 # backungs, storing both base backups and WAL files.
41 basedir = '/srv/backups'
42 accepted_fileclasses = ['pg']
46 syslog.openlog(sys.argv[0], syslog.LOG_PID, syslog.LOG_DAEMON)
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>
53 syslog.syslog(syslog.LOG_INFO, m)
56 syslog.syslog(syslog.LOG_WARNING, m)
57 print >> sys.stderr, m
60 def filename_sanity_check(fn):
61 if re.search("[^a-zA-Z0-9._-]", fn):
62 croak("Invalid characters encountered in '%s'."%(fn))
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))
70 def get_targetdir(classdir, host, create=False):
71 d = os.path.join(classdir, host)
72 if not os.path.exists(d):
74 info("Creating %s"%(d))
77 croak("Targetdir '%s' does not exist."%(d))
80 def sha512_for_file(fn):
84 data = f.read(block_size)
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.")
96 (fileclass, filename, size, checksum) = remote_args
99 if not fileclass in accepted_fileclasses:
100 croak("Invalid file class '%s'"%(fileclass))
103 filename_sanity_check(filename)
105 # check and convert size
109 croak("Invalid size argument '%s'"%(size))
112 if not re.match("^[a-f0-9]{128}$", checksum):
113 croak("Invalid checksum argument '%s'."%(checksum))
115 classdir = get_classdir(fileclass)
116 targetdir = get_targetdir(classdir, host, True)
117 target = os.path.join(targetdir, filename)
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))
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))
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))
131 digest = hashlib.sha512()
133 buf = sys.stdin.read(block_size)
138 running_size += len(buf)
139 if running_size > size:
140 croak("Size mismatch")
142 file_size = os.stat(tmp.name)[stat.ST_SIZE]
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.")
152 os.link(tmp.name, target)
154 croak("Failed at linking to target: %s"%(e))
157 info("Successfully stored %s"%(target))
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.")
165 (fileclass, from_host, filename) = remote_args
168 if not fileclass in accepted_fileclasses:
169 croak("Invalid file class '%s'"%(fileclass))
171 filename_sanity_check(filename)
173 filename_sanity_check(from_host)
175 classdir = get_classdir(fileclass)
176 sourcedir = get_targetdir(classdir, from_host)
177 source = os.path.join(sourcedir, filename)
179 abssource = os.path.abspath(source)
180 dirname = os.path.dirname(abssource)
182 if not dirname in allowed_reads:
183 croak("Host '%s' is not allowed to read from %s"%(host, dirname))
185 if not os.path.exists(abssource):
187 print "Status: 404 not found"
188 info("Not sending %s to remote %s - file does not exist."%(abssource, host))
191 file_size = os.stat(abssource)[stat.ST_SIZE]
192 sha512 = sha512_for_file(abssource)
194 info("Sending %s to remote %s (%s bytes)"%(abssource, host, file_size))
197 print "Status: 200 OK"
198 print "Size: %d"%(file_size)
199 print "SHA-512: %s"%(sha512)
203 data = f.read(block_size)
205 sys.stdout.write(data)
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()
217 def ensure_args_not_empty(remote_args):
218 if len(remote_args) == 0:
219 croak("One more argument expected.")
227 if not 'SSH_ORIGINAL_COMMAND' in os.environ:
228 print >> sys.stderr, "Did not find SSH_ORIGINAL_COMMAND in environment."
231 remote_args = os.environ['SSH_ORIGINAL_COMMAND'].split()
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.")
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)
248 croak("Invalid operation '%s'"%(action))
252 # vim:set shiftwidth=4: