#!/usr/bin/python # hync is a small program to synchronize home user directories in a # decentralized environment. # Copyright (C) 2010 Markus Pargmann (mpargman allfex.org) # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . version = "%prog 0.1.0" license = "hync is a small program to synchronize user home directories without\n"\ "a special server.\n"\ "Copyright (C) 2010 Markus Pargmann (mpargman allfex.org)\n\n"\ \ "This program is free software: you can redistribute it and/or modify\n"\ "it under the terms of the GNU General Public License as published by\n"\ "the Free Software Foundation, either version 3 of the License, or\n"\ "(at your option) any later version.\n"\ \ "This program is distributed in the hope that it will be useful,\n"\ "but WITHOUT ANY WARRANTY; without even the implied warranty of\n"\ "MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n"\ "GNU General Public License for more details.\n\n"\ \ "You should have received a copy of the GNU General Public License\n"\ "along with this program. If not, see ." description = "hync is a program to synchronize user home directories without a special server. Please visit http://allfex.org for more information and help." epilog = "To use this program you have to write your own configuration file. Please have a look at the sample.conf and http://allfex.org for instructions on using this program." import sys import os import subprocess import signal import optparse import configparser import time import socket import atexit import resource check_interval = 300 config = {} locations = {} hosts = {} sync_pts = {} hync_dir = os.path.expanduser(os.path.join('~', '.hync')) hync_run_dir = os.path.join(hync_dir, 'run') hync_config_dir = os.path.join(hync_dir, 'config') active_file = os.path.join(hync_run_dir, 'active') config_file = os.path.join(hync_config_dir, socket.gethostname()) time_state_file = os.path.join(hync_run_dir, 'time_state') time_state = 0 single_run = 0 running = 1 interactive = False push_dis = False pull_dis = False verbose = False writeback_wished = False writeback_done = False first_run = True nopush = False def vprint(out_str): if verbose: print(out_str) def confirmation(question='Confirm?', assume=1): if not interactive: return assume while 1: if assume == 1: print(question + '(Y/n)') confirm = input() if confirm.lower() == 'n': return 0 elif confirm.lower() == 'y' or confirm.lower() == '': return 1 else: print(question + '(y/N)') confirm = input() if confirm.lower() == 'y': return 1 elif confirm.lower() == 'n' or confirm.lower() == '': return 0 def load(file_path): global sync_pts global locations global hosts global config sync_pts = {} locations = {} config = {} vprint('loading config file ' + file_path) if not os.path.exists(file_path): print('ERROR: config file ' + file_path + ' not found.') sys.exit(2) conf_def = {'rsync_syn' : '-aSv --delete', 'exclude' : '.hync/run,.ssh', 'ping_cmd' : 'ping -W 3 -c 2', 'ssh_bin' : 'ssh', 'rsync_bin' : 'rsync', 'user' : ''} conf = configparser.RawConfigParser(conf_def) conf.read(file_path) if not conf.has_section('general'): print('ERROR: section [general] is missing in config file ' + file_path) sys.exit(2) if not conf.has_option('general', 'local_locations'): print('ERROR: local_locations option is missing') sys.exit(2) else: config['local_locations'] = conf.get('general', 'local_locations').split(',') config['ping_cmd'] = conf.get('general', 'ping_cmd') config['ssh_bin'] = conf.get('general', 'ssh_bin') config['rsync_bin'] = conf.get('general', 'rsync_bin') vprint('Got general configuration:') vprint(config) for i in conf.sections(): if i[0:5] == 'host_': host = i[5:] hosts[host] = {} vprint('New host ' + host) for key,val in conf.items(i): if key == 'addr': hosts[host]['addr'] = val.strip() elif key == 'ssh': hosts[host]['ssh'] = val.strip().split(' ') elif key == 'rsync_host': hosts[host]['rsync'] = val.strip().split(' ') elif key == 'user': hosts[host]['user'] = val.strip() if not 'addr' in hosts[host]: print('Config error: Every host needs an address') sys.exit(2) for i in conf.sections(): if i[0:4] == 'loc_': location = i[4:] vprint('New location ' + location) locations[location] = {} for key,val in conf.items(i): if key == 'hosts': locations[location]['hosts'] = [] for host in val.split(','): if not host.strip() in hosts: print('Config error: Host \'' + host.strip() + '\' used by location \'' + location + '\' does not exist') os.remove(active_file) sys.exit(2) locations[location]['hosts'].append(host.strip()) elif key == 'path': locations[location]['path'] = val.strip() if not 'hosts' in locations[location]: print('Config error: Every location needs at least one host') sys.exit(2) for i in conf.sections(): if i[0:5] == 'sync_': loc_name = i[5:].strip() vprint('New sync-point ' + loc_name) sync_pts[loc_name] = {} for key,val in conf.items(i): if key == 'rsync_syn': sync_pts[loc_name]['rsync'] = val.strip().split(' ') elif key == 'locations': remote_hosts = [] involved = 0 for loc in val.split(','): if loc.strip() not in config['local_locations']: if loc.strip() not in locations: print('Config error: Location \'' + loc.strip() + '\' used by sync-point \'' + loc_name + '\' does not exist') sys.exit(2) remote_hosts.append(loc.strip()) else: involved = 1 sync_pts[loc_name]['local_path'] = locations[loc.strip()]['path'] if len(remote_hosts) == 0 or involved == 0: del sync_pts[loc_name] break else: sync_pts[loc_name]['locations'] = remote_hosts elif key == 'exclude': sync_pts[loc_name]['excludes'] = [] for excl in val.split(','): sync_pts[loc_name]['excludes'].append(excl.strip()) def ssh_query(host, cmd): ssh_cmd = [config['ssh_bin']] if 'ssh' in hosts[host]: ssh_cmd.extend(hosts[host]['ssh']) if hosts[host]['user'] != '': ssh_cmd.append('-l') ssh_cmd.append(hosts[host]['user']) ssh_cmd.append(hosts[host]['addr']) ssh_cmd.append(cmd) vprint(' '.join(ssh_cmd)) p = subprocess.Popen(ssh_cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) result, err = p.communicate() if p.returncode != 0: raise Exception('') result = result.decode('utf8') return result def changes_pull(host, remote_path, local_path, rsync_cmd): if pull_dis or not first_run: return cmd = [config['rsync_bin']] if 'ssh' in hosts[host]: cmd.append('-e') cmd.append(config['ssh_bin'] + ' ' + ' '.join(hosts[host]['ssh'])) cmd.extend(rsync_cmd) if hosts[host]['user'] != '': cmd.append(hosts[host]['user'] + '@' + hosts[host]['addr'] + ':' + remote_path) else: cmd.append(hosts[host]['addr'] + ':' + remote_path) cmd.append(local_path) if not confirmation('Would sync ' + local_path + ' <- ' + hosts[host]['addr'] + ':' + remote_path + '. Continue?'): return vprint(local_path + ' <- ' + hosts[host]['addr'] + ':' + remote_path) vprint(' '.join(cmd)) subprocess.call(cmd) def changes_push(host, remote_path, local_path, rsync_cmd): if push_dis or (first_run and nopush): return False cmd = [config['rsync_bin']] if 'ssh' in hosts[host]: cmd.append('-e') cmd.append(config['ssh_bin'] + ' ' + ' '.join(hosts[host]['ssh'])) cmd.extend(rsync_cmd) cmd.append(local_path) if hosts[host]['user'] != '': cmd.append(hosts[host]['user'] + '@' + hosts[host]['addr'] + ':' + remote_path) else: cmd.append(hosts[host]['addr'] + ':' + remote_path) if not confirmation('Would sync ' + local_path + ' -> ' + hosts[host]['addr'] + ':' + remote_path + '. Continue?'): return False vprint(local_path + ' -> ' + hosts[host]['addr'] + ':' + remote_path) vprint(' '.join(cmd)) subprocess.call(cmd) return True def sync_with_location(location, local_path, rsync_cmd): remote_host = '' remote_active = 0 remote_path = locations[location]['path'] if 'rsync' in locations[location]: rsync_cmd.append(locations[location]['rsync']) for host in locations[location]['hosts']: try: addr = hosts[host]['addr'] call = config['ping_cmd'].split(' ') call.append(addr) vprint(' '.join(call)) pingp = subprocess.Popen(call, stdout=subprocess.PIPE, stderr=subprocess.PIPE) pingp.wait() if pingp.returncode != 0: vprint('FAILED') continue remote_active = int(ssh_query(host, 'if [ -e ~/.hync/run/active ] ; then cat ~/.hync/run/active ; else echo "0"; fi')) remote_host = host if 'rsync' in hosts[host]: rsync_cmd.append(hosts[host]['rsync']) break except: continue if remote_host == '': return try: remote_time_state = int(ssh_query(host, 'if [ -s ~/.hync/run/time_state ] ; then cat ~/.hync/run/time_state ; else echo "0"; fi')) except: return if time_state < remote_time_state: if remote_active != 0: if first_run: changes_pull(remote_host, remote_path, local_path, rsync_cmd) else: return else: changes_pull(remote_host, remote_path, local_path, rsync_cmd) else: if remote_active != 0: return else: if changes_push(remote_host, remote_path, local_path, rsync_cmd): ssh_query(remote_host, 'mkdir -p ~/.hync/run/; echo -n ' + str(time_state) + ' > ~/.hync/run/time_state') def stop_handler(signal, frame): global running running = 0 def run(): global first_run global single_run global running global time_state vprint('start running') while running != 0: vprint('new run') if first_run == False: time.sleep(check_interval) if running == 0: break if single_run != 0: running = 0 for tmp, syn_dat in sync_pts.items(): rsync_options = syn_dat['rsync'][:] vprint("rsync opts:") vprint(' '.join(rsync_options)) if 'excludes' in syn_dat: for excl in syn_dat['excludes']: rsync_options.append('--exclude') rsync_options.append(excl) vprint("excludes:") vprint(' '.join(syn_dat['excludes'])) for loc in syn_dat['locations']: sync_with_location(loc, syn_dat['local_path'], rsync_options) time_state = int(time.time()) f = open(time_state_file, 'w') f.write(str(time_state)) f.close() # if running != 0: # time.sleep(check_interval) first_run = False running = 1 def daemonize(): vprint('Forking to background') pid = os.fork() if pid == 0: os.setsid() pid2 = os.fork() if pid2 != 0: os._exit(0) else: os._exit(0) maxfd = resource.getrlimit(resource.RLIMIT_NOFILE)[1] for fd in range(0, maxfd): try: os.close(fd) except OSError: pass os.open('/dev/null', os.O_RDWR) os.dup2(0, 1) os.dup2(0, 2) def on_exit(): try: os.remove(active_file) except: pass def main(): global push_dis global pull_dis global interactive global time_state global verbose global single_run global writeback_wished global first_run global config_file global check_interval global nopush first_run = True opts = optparse.OptionParser(description=description, epilog=epilog, version=version) opts.add_option('-i', '--non-interactive', action='store_false', dest='interactive', default=True, help='Disable Interactive mode') opts.add_option('-v', '--verbose', action='store_true', dest='verbose', default=False, help='Verbose output-mode') opts.add_option('-d', '--daemon', action='store_true', dest='daemon', default=False, help='Run as daemon-process') opts.add_option('-s', '--disable_push', action='store_true', dest='push_dis', default=False, help='Disable pushing changes') opts.add_option('-l', '--disable_pull', action='store_true', dest='pull_dis', default=False, help='Disable pulling changes') opts.add_option('-r', '--reset', action='store_true', dest='reset', default=False, help='Reset the time on this profile') opts.add_option('-m', '--daemonize-im', action='store_true', dest='daemonize_im', default=False, help='Disable first-run observation, daemonize immediately') opts.add_option('-w', '--writeback', action='store_true', dest='writeback', default=False, help='On Program stop, force a writeback of data') opts.add_option('-o', '--onerun', action='store_true', dest='onerun', default=False, help='Start the program only for one run') opts.add_option('-c', '--config', action='store', dest='config_file', default=config_file, help='Configuration file') opts.add_option('-n', '--interval', action='store', dest='interval', default='300', help='Check interval for synchronization (default: 300)') opts.add_option('-p', '--no-push-first', action='store_true', dest='nopush', default=False, help='Disable pushing changes in the first round') opts.add_option('-L', '--license', action='store_true', dest='license', default=False, help='License information') (options, arguments) = opts.parse_args() if options.license: print(license) sys.exit(0) pull_dis = options.pull_dis push_dis = options.push_dis verbose = options.verbose interactive = options.interactive writeback_wished = options.writeback check_interval = int(options.interval) config_file = options.config_file nopush = options.nopush if not os.path.isdir(hync_dir): os.mkdir(hync_dir) if not os.path.isdir(hync_run_dir): os.mkdir(hync_run_dir) if not os.path.isdir(hync_config_dir): os.mkdir(hync_config_dir) if options.reset: interactive = True if os.path.exists(active_file) and confirmation('Reset running file?', assume=0) != 0: os.remove(active_file) if os.path.exists(time_state_file) and confirmation('Reset time state?', assume=0) != 0: os.remove(time_state_file) sys.exit(0) if os.path.exists(active_file): print('There already is a activity-file, perhaps a hync-daemon is already running? Execute with option --reset') sys.exit(2) if os.path.exists(time_state_file): time_state = int(open(time_state_file, 'r').read().strip()) else: time_state = 0 f = open(time_state_file, 'w') f.write('0') f.close() vprint('Creating active file ' + active_file) f = open(active_file, 'w') f.write(str(int(time.time()))) f.close() atexit.register(on_exit) load(config_file) if options.onerun: single_run = 1 else: single_run = 0 if options.daemon and not options.daemonize_im: single_run = 1 run() if not options.onerun: daemonize() single_run = 0 interactive = 0 verbose = 0 run() elif options.daemon: interactive = 0 verbose = 0 daemonize() run() else: run() os.remove(active_file) pull_dis = True if writeback_wished: single_run = 1 run() global writeback_done writeback_done = True signal.signal(signal.SIGABRT, stop_handler) signal.signal(signal.SIGINT, stop_handler) signal.signal(signal.SIGTERM, stop_handler) main()