3
# Turku backups - client agent
4
# Copyright 2015 Canonical Ltd.
6
# This program is free software: you can redistribute it and/or modify it
7
# under the terms of the GNU General Public License version 3, as published by
8
# the Free Software Foundation.
10
# This program is distributed in the hope that it will be useful, but WITHOUT
11
# ANY WARRANTY; without even the implied warranties of MERCHANTABILITY,
12
# SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
13
# General Public License for more details.
15
# You should have received a copy of the GNU General Public License along with
16
# this program. If not, see <http://www.gnu.org/licenses/>.
18
from __future__ import print_function
32
CONFIG_D = '/etc/turku-agent/config.d'
33
SOURCES_D = '/etc/turku-agent/sources.d'
34
SOURCES_SECRETS_D = '/etc/turku-agent/sources_secrets.d'
35
SSH_PRIVATE_KEY = '/etc/turku-agent/id_rsa'
36
SSH_PUBLIC_KEY = '/etc/turku-agent/id_rsa.pub'
37
RSYNCD_CONF = '/etc/turku-agent/rsyncd.conf'
38
RSYNCD_SECRETS = '/etc/turku-agent/rsyncd.secrets'
39
VAR_DIR = '/var/lib/turku-agent'
40
RESTORE_DIR = '/var/backups/turku-agent/restore'
43
def json_dump_p(obj, f):
44
"""Calls json.dump with standard (pretty) formatting"""
45
return json.dump(obj, f, sort_keys=True, indent=4, separators=(',', ': '))
48
def json_dumps_p(obj):
49
"""Calls json.dumps with standard (pretty) formatting"""
50
return json.dumps(obj, sort_keys=True, indent=4, separators=(',', ': '))
54
"""Recursively merge one dict into another."""
55
if not isinstance(m, dict):
57
out = copy.deepcopy(s)
58
for k, v in m.items():
59
if k in out and isinstance(out[k], dict):
60
out[k] = dict_merge(out[k], v)
62
out[k] = copy.deepcopy(v)
67
for d in (CONFIG_D, SOURCES_D, VAR_DIR):
68
if not os.path.isdir(d):
70
for d in (SOURCES_SECRETS_D,):
71
if not os.path.isdir(d):
74
for f in (SSH_PRIVATE_KEY, SSH_PUBLIC_KEY, RSYNCD_CONF, RSYNCD_SECRETS):
75
d = os.path.dirname(f)
76
if not os.path.isdir(d):
81
# Merge in config.d/*.json to the root level
82
config_files = [os.path.join(CONFIG_D, fn) for fn in os.listdir(CONFIG_D) if fn.endswith('.json') and os.path.isfile(os.path.join(CONFIG_D, fn)) and os.access(os.path.join(CONFIG_D, fn), os.R_OK)]
84
for file in config_files:
87
root_config = dict_merge(root_config, j)
89
# Validate the unit name
90
if not 'unit_name' in root_config:
91
root_config['unit_name'] = platform.node()
92
# If this isn't in the on-disk config, don't write it; just
93
# generate it every time
95
# Validate the machine UUID/secret
96
write_uuid_data = False
97
if not 'machine_uuid' in root_config:
98
root_config['machine_uuid'] = str(uuid.uuid4())
99
write_uuid_data = True
100
if not 'machine_secret' in root_config:
101
root_config['machine_secret'] = ''.join(random.choice(string.ascii_letters + string.digits) for i in range(30))
102
write_uuid_data = True
103
# Write out the machine UUID/secret if needed
105
with open(os.path.join(CONFIG_D, '10-machine_uuid.json'), 'w') as f:
106
os.chmod(os.path.join(CONFIG_D, '10-machine_uuid.json'), 0o600)
107
json_dump_p({'machine_uuid': root_config['machine_uuid'], 'machine_secret': root_config['machine_secret']}, f)
109
# Restoration configuration
110
write_restore_data = False
111
if not 'restore_path' in root_config:
112
root_config['restore_path'] = RESTORE_DIR
113
write_restore_data = True
114
if not 'restore_module' in root_config:
115
root_config['restore_module'] = 'turku-restore'
116
write_restore_data = True
117
if not 'restore_username' in root_config:
118
root_config['restore_username'] = str(uuid.uuid4())
119
write_restore_data = True
120
if not 'restore_password' in root_config:
121
root_config['restore_password'] = ''.join(random.choice(string.ascii_letters + string.digits) for i in range(30))
122
write_restore_data = True
123
if write_restore_data:
124
with open(os.path.join(CONFIG_D, '10-restore.json'), 'w') as f:
125
os.chmod(os.path.join(CONFIG_D, '10-restore.json'), 0o600)
127
'restore_path': root_config['restore_path'],
128
'restore_module': root_config['restore_module'],
129
'restore_username': root_config['restore_username'],
130
'restore_password': root_config['restore_password'],
132
json_dump_p(restore_out, f)
133
if not os.path.isdir(root_config['restore_path']):
134
os.makedirs(root_config['restore_path'])
136
# Generate the SSH keypair if it doesn't exist
137
if not os.path.isfile(SSH_PUBLIC_KEY):
138
subprocess.check_call(['ssh-keygen', '-t', 'rsa', '-N', '', '-C', 'turku', '-f', SSH_PRIVATE_KEY])
140
# Pull the SSH public key
141
with open(SSH_PUBLIC_KEY) as f:
142
root_config['ssh_public_key'] = f.read().rstrip()
145
# Merge in sources.d/*.json to the sources dict
146
sources_files = [os.path.join(SOURCES_D, fn) for fn in os.listdir(SOURCES_D) if fn.endswith('.json') and os.path.isfile(os.path.join(SOURCES_D, fn)) and os.access(os.path.join(SOURCES_D, fn), os.R_OK)]
148
for file in sources_files:
149
with open(file) as f:
152
# Ignore incomplete source entries
153
if not 'path' in j[s]:
154
print('WARNING: Path not found for "%s", not using.' % s, file=sys.stderr)
156
sources_config = dict_merge(sources_config, j)
158
for s in sources_config:
159
# Check for missing usernames/passwords
160
if not ('username' in sources_config[s] or 'password' in sources_config[s]):
161
# If they're in sources_secrets.d, use them
162
if os.path.isfile(os.path.join(SOURCES_SECRETS_D, s + '.json')):
163
with open(os.path.join(SOURCES_SECRETS_D, s + '.json')) as f:
165
sources_config = dict_merge(sources_config, {s: j})
166
# Check again and generate sources_secrets.d if still not found
167
if not ('username' in sources_config[s] or 'password' in sources_config[s]):
168
if not 'username' in sources_config[s]:
169
sources_config[s]['username'] = str(uuid.uuid4())
170
if not 'password' in sources_config[s]:
171
sources_config[s]['password'] = ''.join(random.choice(string.ascii_letters + string.digits) for i in range(30))
172
with open(os.path.join(SOURCES_SECRETS_D, s + '.json'), 'w') as f:
173
json_dump_p({'username': sources_config[s]['username'], 'password': sources_config[s]['password']}, f)
175
root_config['sources'] = sources_config
180
def api_call(api_url, cmd, post_data, timeout=5):
181
url = urlparse.urlparse(api_url)
182
if url.scheme == 'https':
183
h = httplib.HTTPSConnection(url.netloc, timeout=timeout)
185
h = httplib.HTTPConnection(url.netloc, timeout=timeout)
186
out = json.dumps(post_data)
187
h.putrequest('POST', '%s/%s' % (url.path, cmd))
188
h.putheader('Content-Type', 'application/json')
189
h.putheader('Content-Length', len(out))
190
h.putheader('Accept', 'application/json')
194
res = h.getresponse()
195
if not res.status == httplib.OK:
196
raise Exception('Received error %d (%s) from API server' % (res.status, res.reason))
197
if not res.getheader('content-type') == 'application/json':
198
raise Exception('Received invalid reply from API server')
200
return json.load(res)
202
raise Exception('Received invalid reply from API server')