Project

General

Profile

Download (17.5 KB) Statistics
| Branch: | Tag: | Revision:
1
<?php
2
/*
3
 * autoconfigbackup.inc
4
 *
5
 * part of pfSense (https://www.pfsense.org)
6
 * Copyright (c) 2008-2013 BSD Perimeter
7
 * Copyright (c) 2013-2016 Electric Sheep Fencing
8
 * Copyright (c) 2014-2025 Rubicon Communications, LLC (Netgate)
9
 * All rights reserved.
10
 *
11
 * Licensed under the Apache License, Version 2.0 (the "License");
12
 * you may not use this file except in compliance with the License.
13
 * You may obtain a copy of the License at
14
 *
15
 * http://www.apache.org/licenses/LICENSE-2.0
16
 *
17
 * Unless required by applicable law or agreed to in writing, software
18
 * distributed under the License is distributed on an "AS IS" BASIS,
19
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
20
 * See the License for the specific language governing permissions and
21
 * limitations under the License.
22
 */
23

    
24
require_once("config.inc");
25
require_once("filter.inc");
26
require_once("notices.inc");
27

    
28
global $acb_base_url;
29
$acb_base_url = "https://acb.netgate.com";
30

    
31
global $acb_last_backup_file;
32
$acb_last_backup_file = "/cf/conf/lastACBentry.txt";
33

    
34
global $acb_force_file;
35
$acb_force_file = "/tmp/forceacb";
36

    
37
/* Set up time zones for conversion. See #5250 */
38
global $acb_server_tz;
39
$acb_server_tz = new DateTimeZone('America/Chicago');
40

    
41
/* Backup reason strings for which ACB will not create remote backup entries */
42
global $acb_ignore_reasons;
43
$acb_ignore_reasons = [
44
	'snort',
45
	'pfblocker',
46
	'minicron',
47
	'merged in config',
48
	'intermediate config write during package',
49
	'acbupload.php',
50
	'execacb.php'
51
];
52

    
53
/* Check a string to determine if it is a valid device key */
54
function is_valid_acb_device_key($dk) {
55
	$dk = trim($dk);
56
	if (!is_null($dk) &&
57
	    !empty($dk) &&
58
	    (strlen($dk) == 64) &&
59
	    ctype_xdigit($dk)) {
60
		return true;
61
	}
62
	return false;
63
}
64

    
65
/* Check a string to determine if it is a valid revision identifier */
66
function is_valid_acb_revision($revision) {
67
	/* The revision must be a valid date string */
68
	/* Ensure this returns boolean true/false not a timestamp when true */
69
	return (strtotime($revision) !== false);
70
}
71

    
72
/* Check if a reason string should be ignored by ACB. */
73
function is_acb_ignored_reason($reason) {
74
	global $acb_ignore_reasons;
75
	foreach ($acb_ignore_reasons as $term) {
76
		if (stripos($reason, $term) !== false) {
77
			return true;
78
		}
79
	}
80
	return false;
81
}
82

    
83
/* Generate a new random device key */
84
function acb_generate_device_key() {
85
	$keyoutput = "";
86
	$keystatus = "";
87
	exec("/bin/dd status=none if=/dev/random bs=4096 count=1 | /usr/bin/openssl sha256 | /usr/bin/cut -f2 -d' '", $keyoutput, $keystatus);
88
	if (($keystatus == 0) &&
89
	    is_array($keyoutput)) {
90
		$keyoutput = trim($keyoutput[0]);
91

    
92
		if (is_valid_acb_device_key($keyoutput)) {
93
			return $keyoutput;
94
		}
95
	}
96
	return null;
97
}
98

    
99
/* Locate a legacy ACB key for a device, which is derived from the SSH key */
100
function get_acb_legacy_device_key() {
101
	if (file_exists('/etc/ssh/ssh_host_ed25519_key.pub')) {
102
		$pkey =  file_get_contents("/etc/ssh/ssh_host_ed25519_key.pub");
103
		// Check that the SSH key looks reasonable
104
		if (substr($pkey, 0, 3) == "ssh") {
105
			return hash("sha256", $pkey);
106
		}
107
	}
108
	return null;
109
}
110

    
111
/* Locate and return the ACB device key for this installation. If there is no
112
 * viable key, generate and store a new key. */
113
function get_acb_device_key() {
114
	$config_device_key = config_get_path('system/acb/device_key');
115

    
116
	/* If there is no device key in the configuration, check for a legacy key */
117
	if (!is_valid_acb_device_key($config_device_key) &&
118
	    acb_enabled() &&
119
	    file_exists('/etc/ssh/ssh_host_ed25519_key.pub')) {
120
		$config_device_key = get_acb_legacy_device_key();
121
	}
122

    
123
	/* Still no key, so generate a new random key */
124
	if (!is_valid_acb_device_key($config_device_key)) {
125
		$config_device_key = acb_generate_device_key();
126
		/* Only store the key if it's valid */
127
		if (is_valid_acb_device_key($config_device_key)) {
128
			config_set_path('system/acb/device_key', $config_device_key);
129
			write_config(gettext('Generated new randomized AutoConfigBackup device key'));
130
		}
131
	}
132

    
133
	/* Still no valid key, something went wrong */
134
	if (!is_valid_acb_device_key($config_device_key)) {
135
		log_error(gettext('Unable to locate or generate a valid AutoConfigBackup device key'));
136
		return null;
137
	} else {
138
		return $config_device_key;
139
	}
140
}
141

    
142
/* Check whether ACB is enabled */
143
function acb_enabled() {
144
	return (config_get_path('system/acb/enable', '') == "yes");
145
}
146

    
147
/* Check if this device can resolve the ACB hostname via DNS. */
148
function acb_check_dns() {
149
	global $acb_base_url;
150

    
151
	if (!resolve_address($acb_base_url)) {
152
		acb_error_log(sprintf(gettext('Unable to resolve %s'),
153
			parse_url($acb_base_url, PHP_URL_HOST)));
154
		return false;
155
	} else {
156
		return true;
157
	}
158
}
159

    
160
/* Change the time zone to reflect local time of ACB revisions.
161
 * See Redmine #5250 */
162
function acb_time_shift($revision, $format = DATE_RFC2822) {
163
	global $acbtz;
164
	$budate = new DateTime($revision, $acbtz);
165
	$mytz = new DateTimeZone(date_default_timezone_get());
166
	$budate->setTimezone($mytz);
167
	return htmlspecialchars($budate->format($format));
168
}
169

    
170
/*
171
 * Query the ACB server via cURL and return the data
172
 *
173
 * Parameters:
174
 *   endpoint:
175
 *      Relative URL endpoint on the ACB service, not including the base
176
 *      hostname.
177
 *   postfields:
178
 *      Array containing post fields and their values to submit.
179
 *   multipart:
180
 *      True when submitting multi-part form data (e.g. save/upload)
181
 *
182
 * Returns:
183
 *   data:
184
 *     Content returned from the server
185
 *   httpcode:
186
 *     HTTP code returned by the server
187
 */
188
function acb_query_service($endpoint, $post_fields, $multipart = false) {
189
	global $acb_base_url;
190
	$url = "{$acb_base_url}/{$endpoint}";
191

    
192
	/* Bail if passed invalid data */
193
	if (empty($endpoint) ||
194
	    empty($post_fields) ||
195
	    !is_array($post_fields)) {
196
		return [null, null, 1];
197
	}
198

    
199
	/* Add UID */
200
	$post_fields['uid'] = system_get_uniqueid();
201

    
202
	/* Store this now as it may be lost in the next step. */
203
	$post_fields_count = count($post_fields);
204
	if (!$multipart) {
205
		$post_fields = http_build_query($post_fields);
206
	}
207

    
208
	$curl_session = curl_init();
209
	curl_setopt($curl_session, CURLOPT_URL, $url);
210
	curl_setopt($curl_session, CURLOPT_POST, $post_fields_count);
211
	curl_setopt($curl_session, CURLOPT_POSTFIELDS, $post_fields);
212
	curl_setopt($curl_session, CURLOPT_RETURNTRANSFER, 1);
213
	curl_setopt($curl_session, CURLOPT_SSL_VERIFYPEER, 1);
214
	curl_setopt($curl_session, CURLOPT_CONNECTTIMEOUT, 55);
215
	curl_setopt($curl_session, CURLOPT_TIMEOUT, 30);
216
	curl_setopt($curl_session, CURLOPT_USERAGENT, g_get('product_label') . '/' . rtrim(file_get_contents("/etc/version")));
217
	// Proxy
218
	set_curlproxy($curl_session);
219

    
220
	$data = curl_exec($curl_session);
221
	$httpcode = curl_getinfo($curl_session, CURLINFO_RESPONSE_CODE);
222
	$errno = curl_errno($curl_session);
223

    
224
	if ($errno) {
225
		$fd = fopen("/tmp/acb_debug.txt", "w");
226
		fwrite($fd, $url . "\n\n");
227
		fwrite($fd, var_export($post_fields, true));
228
		fwrite($fd, $data);
229
		fwrite($fd, curl_error($curl_session));
230
		fclose($fd);
231
	} else {
232
		curl_close($curl_session);
233
	}
234
	return [$data, $httpcode, $errno];
235
}
236

    
237
/* Check if a backup is necessary (has config changed since last upload) */
238
function is_acb_upload_needed() {
239
	global $acb_last_backup_file;
240

    
241
	if (file_exists($acb_last_backup_file)) {
242
		$last_backup_date = trim(file_get_contents($acb_last_backup_file));
243
	} else {
244
		$last_backup_date = "";
245
	}
246

    
247
	return ($last_backup_date <> config_get_path('revision/time'));
248
}
249

    
250
/* Stage a config backup for uploading which will be picked up later by the
251
 * acbupload.php cron job which performs the actual upload process.
252
 */
253
function acb_backup_stage_upload($manual = false) {
254
	global $acb_base_url;
255

    
256
	/* Do nothing when booting or when not enabled */
257
	if (is_platform_booting() ||
258
	    !acb_enabled()) {
259
		return;
260
	}
261

    
262
	/* Define required variables */
263
	$userkey = get_acb_device_key();
264
	$hostname = config_get_path('system/hostname') . "." . config_get_path('system/domain');
265
	$reason = config_get_path('revision/description');
266
	$manmax = config_get_path('system/acb/numman', '0');
267
	$encryptpw = config_get_path('system/acb/encryption_password');
268

    
269
	if (is_acb_ignored_reason($reason)) {
270
		log_error(sprintf(gettext('Skipping staging AutoConfigBackup entry for ignored reason: %s.'), $reason));
271
		return;
272
	}
273

    
274
	if (!$encryptpw) {
275
		if (!file_exists("/cf/conf/autoconfigback.notice")) {
276
			$notice_text = gettext('The Automatic Configuration Backup Encryption Password is not set. ' .
277
				'Configure the Encryption Password at Services > AutoConfigBackup > Settings.');
278
			log_error($notice_text);
279
			file_notice("AutoConfigBackup", $notice_text, $notice_text, "");
280
			touch("/cf/conf/autoconfigback.notice");
281
		}
282
	} else {
283
		/* If the configuration has changed, upload to ACB service */
284
		if (is_acb_upload_needed()) {
285
			$notice_text = sprintf(gettext('Staging AutoConfigBackup encrypted configuration backup for deferred upload to %s'), $acb_base_url);
286
			log_error($notice_text);
287
			update_filter_reload_status($notice_text);
288

    
289
			/* Encrypt config.xml contents */
290
			$data = file_get_contents("/cf/conf/config.xml");
291
			$raw_config_sha256_hash = trim(shell_exec("/sbin/sha256 /cf/conf/config.xml | /usr/bin/awk '{ print $4 }'"));
292
			$data = encrypt_data($data, $encryptpw);
293
			$tmpname = "/tmp/" . $raw_config_sha256_hash . ".tmp";
294
			tagfile_reformat($data, $data, "config.xml");
295
			file_put_contents($tmpname, $data);
296

    
297
			/* Define backup metadata */
298
			$post_fields = array(
299
				'reason' => substr(htmlspecialchars($reason), 0, 1024),
300
				'file' => curl_file_create($tmpname, 'image/jpg', 'config.jpg'),
301
				'userkey' => htmlspecialchars($userkey),
302
				'sha256_hash' => $raw_config_sha256_hash,
303
				'version' => g_get('product_version'),
304
				'hint' => substr(config_get_path('system/acb/hint'), 0, 255),
305
				'manmax' => (int)$manmax
306
			);
307

    
308
			unlink_if_exists($tmpname);
309

    
310
			/* Location to stage backup file pairs */
311
			$acbuploadpath = g_get('acbbackuppath');
312

    
313
			if (!is_dir($acbuploadpath)) {
314
				mkdir($acbuploadpath);
315
			}
316

    
317
			file_put_contents($acbuploadpath . $post_fields['sha256_hash'] . ".form", json_encode($post_fields));
318
			file_put_contents($acbuploadpath . $post_fields['sha256_hash'] . ".data", $data);
319
		} else {
320
			/* Debugging */
321
			//log_error(gettext('No AutoConfigBackup action required.'));
322
		}
323
	}
324
}
325

    
326
/* Upload all backup entries staged by acb_backup_stage_upload(). */
327
function acb_backup_upload($basename) {
328
	global $acb_base_url, $acb_last_backup_file;
329

    
330
	/* Location of staged backup file pairs */
331
	$acbuploadpath = g_get('acbbackuppath');
332

    
333
	/* If the ACB service cannot be resolved, remove staged backup files
334
	 * and exit.
335
	 * The check function logs an error, no need to log an error manually.
336
	 */
337
	if (!acb_check_dns()) {
338
		unlink_if_exists($acbuploadpath . $basename . ".data");
339
		unlink_if_exists($acbuploadpath . $basename . ".form");
340
		return;
341
	}
342

    
343
	/* Read the staged form file containing backup metadata */
344
	$formdata = file_get_contents($acbuploadpath . $basename . ".form");
345
	$post_fields = json_decode($formdata, true);
346

    
347
	/* Check backup reason in metadata against ignore list */
348
	if (is_acb_ignored_reason($post_fields['reason'])) {
349
		log_error(sprintf(gettext('Skipping staged AutoConfigBackup entry for ignored reason: %s.'), $post_fields['reason']));
350
		/* Delete the staged backup files */
351
		unlink_if_exists($acbuploadpath . $basename . ".data");
352
		unlink_if_exists($acbuploadpath . $basename . ".form");
353
		return;
354
	}
355

    
356
	/* Add the encrytped backup data */
357
	$post_fields['file'] = curl_file_create($acbuploadpath . $basename . ".data", 'image/jpg', 'config.jpg');
358

    
359
	/* Upload encrypted backup entry and its metadata to the ACB service */
360
	[$data, $httpcode, $errno] = acb_query_service("save", $post_fields, true);
361

    
362
	/* Delete the staged backup files no matter the outcome */
363
	unlink_if_exists($acbuploadpath . $basename . ".data");
364
	unlink_if_exists($acbuploadpath . $basename . ".form");
365

    
366
	if (strpos(strval($httpcode), '20') === false) {
367
		if (empty($data) && $errno) {
368
			$data = $errno;
369
		} else {
370
			$data = "Unknown error";
371
		}
372
		acb_error_log($data);
373
	} else {
374
		/* Update last ACB backup time */
375
		$fd = fopen($acb_last_backup_file, "w");
376
		fwrite($fd, config_get_path('revision/time'));
377
		fclose($fd);
378
		$notice_text = sprintf(gettext('Completed AutoConfigBackup encrypted configuration backup upload to %s (success)'), $acb_base_url);
379
		log_error($notice_text);
380
		update_filter_reload_status($notice_text);
381
	}
382
}
383

    
384
/* Get a specific backup entry from the ACB service */
385
function acb_backup_get($userkey, $revision) {
386
	$post_fields = [
387
		'userkey'  => $userkey,
388
		'revision' => $revision,
389
		'version'  => g_get('product_version'),
390
	];
391
	return acb_query_service("getbkp", $post_fields);
392
}
393

    
394
/* Get metadata for a specific backup entry from the list as this
395
 * metadata is not included when using the getbkp endpoint.
396
 */
397
function acb_backup_get_metadata($userkey, $revision) {
398
	/* Reverse the list since the ACB server getbkp returns last match,
399
	 * otherwise if two entries have the same revision the metadata will
400
	 * not match. */
401
	$backups = array_reverse(acb_backup_list($userkey));
402
	foreach ($backups as $b) {
403
		if ($b['time'] == $revision) {
404
			return $b;
405
		}
406
	}
407
	return [];
408
}
409

    
410
/* Decrypt the configuration data from an ACB service backup entry. */
411
function acb_backup_decrypt($data, $password) {
412
	$errors = [];
413

    
414
	$data_split = explode('++++', $data);
415
	$sha256 = trim($data_split[0]);
416
	$encrypted = $data_split[1];
417

    
418
	if (!tagfile_deformat($encrypted, $encrypted, "config.xml")) {
419
		$errors[] = gettext('The fetched backup entry does not appear to contain an encrypted configuration.');
420
	}
421
	$decrypted = decrypt_data($encrypted, $password);
422
	if (!strstr($decrypted, "pfsense") ||
423
	    (strlen($decrypted) < 50)) {
424
		$errors[] = gettext('Could not decrypt the fetched configuration backup entry. Check the encryption key and try again.');
425
	} else {
426
		$pos = stripos($decrypted, "</pfsense>");
427
		$decrypted = substr($decrypted, 0, $pos);
428
		$decrypted .= "</pfsense>\n";
429
	}
430

    
431
	return [$decrypted, $encrypted, $sha256, $errors];
432
}
433

    
434
/* Fetch a list of backups stored for a given device key on the ACB service */
435
function acb_backup_list($userkey) {
436
	/* Separator used during client / server communications */
437
	$oper_sep = "\|\|";
438

    
439
	$backups = [];
440

    
441
	$post_fields = [
442
		'userkey'  => $userkey,
443
		'version'  => g_get('product_version')
444
	];
445
	/* Fetch backup data for this device key from the ACB service */
446
	[$data, $httpcode, $errno] = acb_query_service("list", $post_fields);
447

    
448
	/* Loop through fetched data and create a backup list */
449
	foreach (explode("\n", $data) as $ds) {
450
		$ds_split = [];
451
		preg_match("/^(.*?){$oper_sep}(.*){$oper_sep}(.*)/", $ds, $ds_split);
452

    
453
		$tmp_array = [
454
			'username'  => $ds_split[1],
455
			'reason'    => $ds_split[2],
456
			'time'      => $ds_split[3],
457
			'localtime' => acb_time_shift($ds_split[3])
458
		];
459

    
460
		if ($ds_split[3] && $ds_split[1]) {
461
			$backups[] = $tmp_array;
462
		}
463
	}
464

    
465
	return $backups;
466
}
467

    
468
/* Delete a specific backup entry from the ACB service */
469
function acb_backup_delete($userkey, $revision) {
470
	global $acb_base_url;
471

    
472
	$savemsg = "";
473

    
474
	$post_fields = [
475
		'userkey'  => $userkey,
476
		'revision' => $revision,
477
		'version'  => g_get('product_version'),
478
	];
479
	[$data, $httpcode, $errno] = acb_query_service("rmbkp", $post_fields);
480

    
481
	if ($errno) {
482
		$savemsg = sprintf(gettext('An error occurred while trying to remove the backup revision from %s'), $acb_base_url);
483
	} else {
484
		$savemsg = sprintf(gettext('Backup revision %s has been removed.'), acb_time_shift($revision));
485
	}
486
	return $savemsg;
487
}
488

    
489
/* Save the ACB configuration.
490
 * Creates or removes ACB crontab entry for scheduled backups when necessary.
491
 */
492
function setup_ACB($enable, $hint, $frequency, $minute, $hours, $month, $day, $dow, $numman, $reverse, $pwd) {
493
	/* Randomize the minutes if not specified */
494
	if (!isset($minute) || strlen($minute) == 0 || $minute == "0") {
495
		$minute = rand(1, 59);
496
	}
497

    
498
	config_set_path('system/acb/enable', $enable);
499
	config_set_path('system/acb/hint', $hint);
500
	config_set_path('system/acb/frequency', $frequency);
501
	config_set_path('system/acb/minute', $minute);
502
	config_set_path('system/acb/hour', $hours);
503
	config_set_path('system/acb/month', $month);
504
	config_set_path('system/acb/day', $day);
505
	config_set_path('system/acb/dow', $dow);
506
	config_set_path('system/acb/numman', $numman);
507
	config_set_path('system/acb/reverse', $reverse);
508
	if (strlen($pwd) >= 8) {
509
		config_set_path('system/acb/encryption_password', $pwd);
510
	}
511

    
512
	/* Install or remove cron job for scheduled periodic backups. */
513
	install_cron_job("/usr/bin/nice -n20 /usr/local/bin/php /usr/local/sbin/execacb.php",
514
		($frequency == "cron"),
515
		$minute,
516
		is_numeric($hours) ? $hours : "*",
517
		is_numeric($day) ? $day : "*",
518
		is_numeric($month) ? $month : "*",
519
		is_numeric($dow) ? $dow : "*"
520
	);
521

    
522
	/* Install or remove cron job for uploading staged backups */
523
	install_cron_job("/usr/bin/nice -n20 /usr/local/bin/php /usr/local/sbin/acbupload.php",
524
		($enable == "yes"),
525
		"*");
526

    
527
	write_config("AutoConfigBackup settings updated");
528

    
529
	return config_get_path('system/acb');
530
}
531

    
532
/* Log ACB errors when necessary. */
533
function acb_error_log($data) {
534
	global $acb_base_url;
535
	$notice_text = sprintf(
536
		gettext("An error occurred while uploading the encrypted %s configuration backup to %s (%s)"),
537
		g_get('product_label'),
538
		$acb_base_url,
539
		htmlspecialchars($data));
540
	log_error($notice_text . " - " . $data);
541
	file_notice("AutoConfigBackup", $notice_text);
542
	update_filter_reload_status($notice_text);
543
}
544

    
545
/* Generate a self-contained HTML download link for a device key string. */
546
function acb_key_download_link($name, $key) {
547
	$hostname = config_get_path('system/hostname') . "." . config_get_path('system/domain');
548
	$dltext = gettext('Download This Key');
549
	$keystring = base64_encode($key . "\n");
550

    
551
	return <<<EOL
552
<a download="acb_{$hostname}_{$name}_key.txt"
553
   title="{$dltext}"
554
   href="data:text/plain;base64,{$keystring}">
555
   <i class="fa-solid fa-download"></i></a>
556
EOL;
557

    
558
}
(1-1/61)