-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathparental_control_services.php
More file actions
1687 lines (1482 loc) · 59.3 KB
/
Copy pathparental_control_services.php
File metadata and controls
1687 lines (1482 loc) · 59.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
<?php
/*
* parental_control_services.php
*
* Online Services IP Management for Parental Control
* Allows blocking specific online services (YouTube, Facebook, etc.) by IP ranges
*/
##|+PRIV
##|*IDENT=page-services-parentalcontrol-services
##|*NAME=Services: Parental Control: Online Services
##|*DESCR=Manage online service IP lists for blocking
##|*MATCH=parental_control_services.php*
##|-PRIV
require_once("guiconfig.inc");
require_once("/usr/local/pkg/parental_control.inc");
// CRITICAL FIX v1.4.10+: Start PHP session for persistent URL storage
// BUG: Session URLs were lost between add_url and verify_fetch actions
// ROOT CAUSE: $services_config reloaded from config on each request, losing session-based URLs
// SOLUTION: Store temporary URLs in $_SESSION to persist across POST requests
session_start();
// Safety check: Ensure config is valid
// In pfSense 2.8.x, $config is automatically loaded when config.inc is required
if (!is_array($config) || $config === -1) {
require_once("config.inc");
global $config;
if (!is_array($config) || $config === -1) {
die("Fatal: Unable to load configuration. Please restore from backup.");
}
}
$pgtitle = array(gettext("Services"), gettext("Keekar's Parental Control"), gettext("Online Services"));
$pglinks = array("", "@self", "@self");
// ========================================================================
// Helper Functions for XML-Safe Service Management
// ========================================================================
/**
* Safe wrapper for write_config() with validation
* @param string $desc Description for the config change
* @return bool True if write succeeded, false otherwise
*/
function safe_write_config($desc) {
global $config;
// Validate config before writing
if (!is_array($config) || $config === -1) {
// Config is corrupted, try to reload it
require_once("config.inc");
global $config;
if (!is_array($config) || $config === -1) {
// Can't recover, give up
error_log("Cannot write config: config is corrupted");
return false;
}
}
// Check if installedpackages exists
if (!isset($config['installedpackages'])) {
$config['installedpackages'] = array();
}
// Validate the config structure before writing
if (!isset($config['version']) || !isset($config['system'])) {
error_log("Cannot write config: missing required structure");
return false;
}
// Try to write config
try {
$result = @write_config($desc);
// Check if write_config corrupted the config
if (!is_array($config) || $config === -1) {
// write_config failed and corrupted $config, reload it
require_once("config.inc");
global $config;
return false;
}
return true;
} catch (Exception $e) {
error_log("Failed to write config: " . $e->getMessage());
// Reload config after failure
require_once("config.inc");
global $config;
return false;
} catch (Error $e) {
error_log("Failed to write config: " . $e->getMessage());
// Reload config after failure
require_once("config.inc");
global $config;
return false;
}
}
/**
* Find a service by name in the config array
* @param array $config The services config (numeric array)
* @param string $name The service name to find
* @return array|null The service data or null if not found
*/
function pc_find_service_by_name(&$config, $name) {
if (empty($config)) return null;
foreach ($config as $idx => &$service) {
if (isset($service['name']) && $service['name'] === $name) {
return array('index' => $idx, 'service' => &$service);
}
}
return null;
}
/**
* Add or update a service in the config
* @param array $config The services config (numeric array)
* @param string $name The service name
* @param array $data The service data
*/
function add_or_update_service(&$config, $name, $data) {
$data['name'] = $name; // Ensure name is set
$found = pc_find_service_by_name($config, $name);
if ($found !== null) {
// Update existing
$config[$found['index']] = array_merge($config[$found['index']], $data);
} else {
// Add new
$config[] = $data;
}
}
/**
* Remove a service by name from the config
* @param array $config The services config (numeric array)
* @param string $name The service name to remove
* @return bool True if removed, false if not found
*/
function remove_service_by_name(&$config, $name) {
$found = pc_find_service_by_name($config, $name);
if ($found !== null) {
array_splice($config, $found['index'], 1);
return true;
}
return false;
}
/**
* Convert old associative array format to new numeric array format
* @param array $config The services config
* @return array The converted config
*/
function convert_to_numeric_array($config) {
if (empty($config)) return array();
$new_config = array();
foreach ($config as $key => $service) {
// If key is not numeric, it's old format
if (!is_numeric($key)) {
// Ensure service has a 'name' field
if (!isset($service['name'])) {
$service['name'] = $key;
}
$new_config[] = $service;
} else {
// Already numeric
$new_config[] = $service;
}
}
return $new_config;
}
/**
* Send anonymous telemetry data to GitHub repository for feature improvement
*
* Collects anonymous usage statistics to help improve the Online Services feature.
* NO personally identifiable information is collected (no IPs, MACs, usernames).
*
* @param string $action Action performed (verify, create_alias, monitor_block)
* @param array $data Additional anonymous data (service name, URL count, status)
* @return void
* @since 1.5.0
*/
function pc_send_telemetry($action, $data = array()) {
// Telemetry endpoint (GitHub Issues API or webhook)
$telemetry_url = 'https://api.github.com/repos/YOUR_USERNAME/parental-control-telemetry/issues';
// Build anonymous telemetry payload
$payload = array(
'timestamp' => time(),
'version' => '1.5.0',
'action' => $action,
'data' => $data
);
// Log locally for debugging
error_log("Parental Control Telemetry: " . json_encode($payload));
// TODO: Implement actual GitHub submission
// For now, just log it locally
// In future: Send to GitHub Issues API or webhook
/*
$options = array(
'http' => array(
'method' => 'POST',
'header' => 'Content-Type: application/json',
'content' => json_encode($payload),
'timeout' => 5
)
);
@file_get_contents($telemetry_url, false, stream_context_create($options));
*/
}
/**
* Check if a line is a valid IP address or CIDR block
* @param string $line The line to check
* @return bool True if valid IP/CIDR, false otherwise
*/
function pc_is_valid_ip_or_cidr($line) {
// Check for IPv4 address
if (filter_var($line, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
return true;
}
// Check for IPv6 address
if (filter_var($line, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {
return true;
}
// Check for CIDR notation (IPv4 or IPv6)
if (strpos($line, '/') !== false) {
$parts = explode('/', $line, 2);
$ip = $parts[0];
$mask = $parts[1];
// Validate IP part
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) ||
filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {
// Validate mask is numeric and in valid range
if (is_numeric($mask)) {
$mask_int = intval($mask);
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
return $mask_int >= 0 && $mask_int <= 32;
} else {
return $mask_int >= 0 && $mask_int <= 128;
}
}
}
}
return false;
}
/**
* Resolve a domain to IP addresses
* @param string $domain The domain to resolve
* @return array Array of IP addresses (empty if resolution fails)
*/
function pc_resolve_domain($domain) {
$ips = array();
// Try IPv4 resolution (A records)
$records = @dns_get_record($domain, DNS_A);
if ($records) {
foreach ($records as $record) {
if (isset($record['ip'])) {
$ips[] = $record['ip'];
}
}
}
// Try IPv6 resolution (AAAA records)
$records = @dns_get_record($domain, DNS_AAAA);
if ($records) {
foreach ($records as $record) {
if (isset($record['ipv6'])) {
$ips[] = $record['ipv6'];
}
}
}
return array_unique($ips);
}
/**
* Download URLs synchronously and create table files
* This prevents "Unresolvable alias" errors by ensuring table files exist before filter reload
*
* CRITICAL FIX v1.5.1: Handle domain lists from v2fly community
* - v2fly domain lists contain DOMAINS (not IPs) which pfctl cannot load directly
* - SOLUTION: Filter domains and resolve to IPs, skip unresolvable domains gracefully
* - If domain resolution fails, skip that entry instead of failing entire table
*
* @param string $alias_name The alias name (e.g., PC_Service_YouTube)
* @param array $urls Array of URLs to download
* @return bool True if successful, false otherwise
*/
function pc_download_urls_sync($alias_name, $urls) {
if (empty($urls)) {
return false;
}
$table_file = '/var/db/aliastables/' . $alias_name . '.txt';
@mkdir('/var/db/aliastables', 0755, true);
$all_ips = array();
$domain_count = 0;
$resolved_count = 0;
$failed_domains = array();
foreach ($urls as $url) {
error_log("Downloading URL for {$alias_name}: {$url}");
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_TIMEOUT, 30);
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
$content = curl_exec($ch);
$http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($http_code == 200 && !empty($content)) {
$lines = explode("\n", $content);
$count = 0;
foreach ($lines as $line) {
$line = trim($line);
// Skip empty lines, comments, and special directives
if (empty($line) || $line[0] == '#' || strpos($line, 'include:') === 0 ||
strpos($line, 'domain:') === 0 || strpos($line, 'full:') === 0 ||
strpos($line, 'regexp:') === 0 || strpos($line, 'keyword:') === 0) {
continue;
}
// Check if it's a valid IP or CIDR
if (pc_is_valid_ip_or_cidr($line)) {
$all_ips[] = $line;
$count++;
} else {
// Might be a domain - try to resolve it
$domain_count++;
$resolved_ips = pc_resolve_domain($line);
if (!empty($resolved_ips)) {
foreach ($resolved_ips as $ip) {
$all_ips[] = $ip;
$count++;
}
$resolved_count++;
error_log("Resolved domain {$line} to " . count($resolved_ips) . " IP(s)");
} else {
// Domain resolution failed - skip gracefully
$failed_domains[] = $line;
error_log("Warning: Could not resolve domain '{$line}', skipping entry");
}
}
}
error_log("Downloaded {$count} IPs from {$url}");
if ($domain_count > 0) {
error_log("Processed {$domain_count} domains: {$resolved_count} resolved, " .
count($failed_domains) . " failed");
}
} else {
error_log("Failed to download {$url} (HTTP {$http_code})");
}
}
if (!empty($all_ips)) {
// Remove duplicates and write to file
$all_ips = array_unique($all_ips);
file_put_contents($table_file, implode("\n", $all_ips) . "\n");
chmod($table_file, 0644);
error_log("Wrote " . count($all_ips) . " unique IP entries to {$table_file}");
// Log domain resolution summary
if ($domain_count > 0) {
error_log("Domain resolution summary: {$domain_count} domains found, " .
"{$resolved_count} resolved successfully, " .
count($failed_domains) . " failed (skipped gracefully)");
// Log first few failed domains for debugging
if (!empty($failed_domains)) {
$sample_failed = array_slice($failed_domains, 0, 5);
error_log("Sample failed domains: " . implode(', ', $sample_failed));
}
}
// Load into pf table
exec('/sbin/pfctl -t ' . escapeshellarg($alias_name) . ' -T replace -f ' . escapeshellarg($table_file) . ' 2>&1', $output, $ret);
if ($ret == 0) {
error_log("Loaded {$alias_name} into pf table successfully");
return true;
} else {
error_log("Failed to load {$alias_name} into pf table: " . implode(', ', $output));
return false;
}
}
return false;
}
/**
* Create or update a URL alias for a service
* @param string $service_name The service name
* @param array $service The service configuration
* @return bool True if alias was created/updated, false otherwise
*/
function pc_create_service_url_alias($service_name, $service) {
global $config;
// Skip if no URLs
if (empty($service['urls'])) {
return false;
}
// Ensure aliases structure exists
if (!isset($config['aliases'])) {
$config['aliases'] = array();
}
if (!isset($config['aliases']['alias'])) {
$config['aliases']['alias'] = array();
}
// Clean service name for alias (remove spaces, special chars)
$alias_name = 'PC_Service_' . preg_replace('/[^a-zA-Z0-9_]/', '_', $service_name);
// Get URLs
$urls = is_array($service['urls']) ? $service['urls'] : explode("\n", $service['urls']);
$urls = array_filter(array_map('trim', $urls));
// Skip if no valid URLs
if (empty($urls)) {
return false;
}
// Create alias data using pfSense's native URL Table format
// This matches the format when creating URL (IPs) aliases manually in pfSense UI
// pfSense will automatically download and update IPs from URLs
// CRITICAL: Use 'aliasurl' array (not 'url' string) for multiple URLs to show in UI
// Based on analysis of manually created alias: type='url', aliasurl=array of URLs
$alias_data = array(
'name' => $alias_name,
'type' => 'url', // pfSense uses 'url' type for URL Table (IPs) aliases
'aliasurl' => array_values($urls), // Array of URLs (shows as separate rows in UI)
'updatefreq' => '7', // Update frequency in days
'descr' => "Added By KACI Parental Control (DO NOT EDIT DIRECTLY) - {$service_name}",
'detail' => 'Entry added ' . date('r') // Timestamp like manual aliases
);
// Check if alias already exists
$alias_exists = false;
$alias_index = -1;
foreach ($config['aliases']['alias'] as $index => $alias) {
if (isset($alias['name']) && $alias['name'] === $alias_name) {
$alias_exists = true;
$alias_index = $index;
break;
}
}
if ($alias_exists) {
// Update existing alias
$config['aliases']['alias'][$alias_index] = $alias_data;
} else {
// Create new alias
$config['aliases']['alias'][] = $alias_data;
}
return true;
}
/**
* Remove a URL alias for a service
* @param string $service_name The service name
* @return bool True if alias was removed, false if not found
*/
function pc_remove_service_url_alias($service_name) {
global $config;
if (!isset($config['aliases']['alias'])) {
return false;
}
// Clean service name for alias
$alias_name = 'PC_Service_' . preg_replace('/[^a-zA-Z0-9_]/', '_', $service_name);
// Find and remove the alias
foreach ($config['aliases']['alias'] as $index => $alias) {
if (isset($alias['name']) && $alias['name'] === $alias_name) {
unset($config['aliases']['alias'][$index]);
// Re-index the array
$config['aliases']['alias'] = array_values($config['aliases']['alias']);
return true;
}
}
return false;
}
/**
* Test if a single URL is accessible
* @param string $url The URL to test
* @return bool True if accessible, false otherwise
*/
function pc_test_url_accessibility($url) {
if (empty($url)) {
return false;
}
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_TIMEOUT, 5);
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
curl_setopt($ch, CURLOPT_NOBODY, true); // HEAD request only
$result = curl_exec($ch);
$http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
// Consider 2xx and 3xx as success
return ($http_code >= 200 && $http_code < 400);
}
/**
* Verify service URLs by attempting to fetch them
* @param string $service_name The service name
* @param array $service_config The service configuration
* @return array Result with 'success', 'verified_count', 'total_count', and optionally 'error'
*/
function pc_verify_service_urls($service_name, $service_config) {
if (empty($service_config['urls'])) {
return array(
'success' => false,
'error' => 'No URLs configured for this service',
'verified_count' => 0,
'total_count' => 0,
'url_statuses' => array()
);
}
// Handle both string (newline-separated) and array formats
if (is_array($service_config['urls'])) {
$urls = array_filter(array_map('trim', $service_config['urls']));
} else {
$urls = array_filter(array_map('trim', explode("\n", $service_config['urls'])));
}
$total_count = count($urls);
$verified_count = 0;
$errors = array();
$url_statuses = array();
foreach ($urls as $idx => $url) {
// Skip empty lines and comments
if (empty($url) || strpos($url, '#') === 0) {
continue;
}
// Verify URL is accessible
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_TIMEOUT, 5);
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
curl_setopt($ch, CURLOPT_MAXREDIRS, 5);
curl_setopt($ch, CURLOPT_NOBODY, false); // Get content for basic analysis
$content = curl_exec($ch);
$http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curl_error = curl_error($ch);
curl_close($ch);
// Consider 2xx and 3xx as success
$is_active = ($http_code >= 200 && $http_code < 400);
$content_type = 'unknown';
if ($is_active && !empty($content)) {
// Quick content type detection (first 5 lines for telemetry only)
$lines = array_slice(explode("\n", $content), 0, 5);
$has_ip = false;
$has_domain = false;
foreach ($lines as $line) {
$line = trim($line);
if (empty($line) || strpos($line, '#') === 0) continue;
if (preg_match('/^\d+\.\d+\.\d+\.\d+/', $line)) {
$has_ip = true;
} elseif (preg_match('/^[a-z0-9.-]+\.[a-z]{2,}$/i', $line)) {
$has_domain = true;
}
}
// Simple classification
if ($has_domain && !$has_ip) {
$content_type = 'domains';
} elseif ($has_ip && !$has_domain) {
$content_type = 'ips';
} elseif ($has_ip && $has_domain) {
$content_type = 'mixed';
}
$verified_count++;
} else {
$errors[] = "{$url}: HTTP {$http_code}";
}
// Store individual URL status
$url_statuses[$idx] = array(
'active' => $is_active,
'last_tested' => time(),
'http_code' => $http_code,
'content_type' => $content_type,
'has_domains' => ($content_type === 'domains' || $content_type === 'mixed')
);
}
$success = $verified_count > 0;
$result = array(
'success' => $success,
'verified_count' => $verified_count,
'total_count' => $total_count,
'url_statuses' => $url_statuses
);
if (!$success && !empty($errors)) {
$result['error'] = implode('; ', array_slice($errors, 0, 3)); // Show first 3 errors
}
// INFO: Detect domain lists vs IP lists for informational purposes
// NOTE: pfSense URL aliases CAN handle domain lists - they resolve domains to IPs automatically
// This detection is kept for future analytics but warnings removed (pfSense handles both types)
$domain_urls = array();
$ip_urls = array();
foreach ($url_statuses as $idx => $status) {
if (!empty($status['has_domains'])) {
$domain_urls[] = $urls[$idx];
}
if (!empty($status['content_type']) && $status['content_type'] === 'ips') {
$ip_urls[] = $urls[$idx];
}
}
// Store detection results for telemetry (no user-facing warnings)
if (!empty($domain_urls)) {
$result['domain_detected'] = true;
$result['domain_count'] = count($domain_urls);
}
if (!empty($ip_urls)) {
$result['ip_detected'] = true;
$result['ip_count'] = count($ip_urls);
}
return $result;
}
// Default services with pre-populated URLs (NUMERIC ARRAY - XML-safe!)
$default_services = array(
array(
'name' => 'YouTube',
'urls' => array(
'https://raw.githubusercontent.com/touhidurrr/iplist-youtube/main/lists/cidr4.txt',
'https://raw.githubusercontent.com/touhidurrr/iplist-youtube/main/lists/ipv4.txt',
'https://raw.githubusercontent.com/touhidurrr/iplist-youtube/main/lists/cidr6.txt',
'https://raw.githubusercontent.com/touhidurrr/iplist-youtube/main/lists/ipv6.txt'
),
'description' => 'Video streaming service',
'icon' => 'fa-youtube-play'
),
array(
'name' => 'Facebook',
'urls' => array(
'https://raw.githubusercontent.com/SecOps-Institute/FacebookIPLists/refs/heads/master/facebook_ip_list.lst',
'https://raw.githubusercontent.com/SecOps-Institute/FacebookIPLists/refs/heads/master/facebook_ipv4_cidr_blocks.lst',
'https://raw.githubusercontent.com/SecOps-Institute/FacebookIPLists/refs/heads/master/facebook_ipv6_list.lst'
),
'description' => 'Social media platform (includes Instagram)',
'icon' => 'fa-facebook'
),
array(
'name' => 'WhatsApp',
'urls' => array(
'https://raw.githubusercontent.com/HybridNetworks/whatsapp-cidr/main/WhatsApp/whatsapp_cidr_ipv4.txt'
),
'description' => 'Messaging and voice/video calling platform (owned by Meta)',
'icon' => 'fa-whatsapp'
),
array(
'name' => 'Instagram',
'urls' => array(
'https://raw.githubusercontent.com/SecOps-Institute/FacebookIPLists/refs/heads/master/facebook_ip_list.lst'
),
'description' => 'Photo and video sharing social platform (owned by Meta, shares IPs with Facebook)',
'icon' => 'fa-instagram'
),
array(
'name' => 'Discord',
'urls' => array(
'https://raw.githubusercontent.com/v2fly/domain-list-community/refs/heads/master/data/discord'
),
'description' => '⚠️ Voice, video, and text communication platform (domain list from v2fly community)',
'icon' => 'fa-comments',
'note' => 'Discord domain list includes core domains (discord.com, discord.gg, etc.) and related services. Note: Discord uses Cloudflare CDN which may affect IP-based blocking effectiveness.'
),
array(
'name' => 'TikTok',
'urls' => array(
'https://raw.githubusercontent.com/v2fly/domain-list-community/refs/heads/master/data/tiktok'
),
'description' => 'Short-form video platform (domain list from v2fly community)',
'icon' => 'fa-video-camera'
),
array(
'name' => 'Netflix',
'urls' => array(
'https://raw.githubusercontent.com/SecOps-Institute/NetflixIPLists/master/netflix_ips.txt'
),
'description' => 'Streaming entertainment service',
'icon' => 'fa-film'
),
array(
'name' => 'Twitch',
'urls' => array(
'https://raw.githubusercontent.com/SecOps-Institute/TwitchIPLists/master/twitch_ips.txt'
),
'description' => 'Live streaming platform',
'icon' => 'fa-twitch'
),
array(
'name' => 'Online Gaming',
'urls' => array(
'https://raw.githubusercontent.com/v2fly/domain-list-community/refs/heads/master/data/steam',
'https://raw.githubusercontent.com/v2fly/domain-list-community/refs/heads/master/data/epicgames',
'https://raw.githubusercontent.com/v2fly/domain-list-community/refs/heads/master/data/ea',
'https://raw.githubusercontent.com/v2fly/domain-list-community/refs/heads/master/data/blizzard'
),
'description' => 'Online gaming platforms (Steam, Epic Games, EA, Blizzard/Battle.net) - Works with Gaming Detection feature',
'icon' => 'fa-gamepad',
'note' => 'This service provides domain lists for major gaming platforms. Combine with Gaming Detection feature (port + pattern detection) for comprehensive gaming control. Domain lists from v2fly community.'
)
);
// Load current services config
$services_config = config_get_path('installedpackages/parentalcontrolservices/config', array());
// Convert old format if needed (associative array -> numeric array)
$services_config = convert_to_numeric_array($services_config);
// Initialize with default services if none exist
if (empty($services_config)) {
$services_config = $default_services;
}
// CRITICAL FIX v1.4.10+: Merge session URLs with config URLs
// BUG: Temporary URLs added via add_url were lost on page reload
// ROOT CAUSE: $services_config reloaded fresh from config, losing session URLs
// SOLUTION: Restore session URLs after loading config
if (!isset($_SESSION['pc_service_urls'])) {
$_SESSION['pc_service_urls'] = array();
}
// BUGFIX v1.4.34: Initialize session storage for new services
if (!isset($_SESSION['pc_new_services'])) {
$_SESSION['pc_new_services'] = array();
}
// BUGFIX v1.4.34: Restore session-based new services (not yet saved to config)
// These are services added via "Add New Service" but not yet made permanent via "Monitor & Block"
if (!empty($_SESSION['pc_new_services'])) {
foreach ($_SESSION['pc_new_services'] as $session_service) {
// Check if service already exists in config (avoid duplicates)
$existing = pc_find_service_by_name($services_config, $session_service['name']);
if ($existing === null) {
// Add session service to $services_config for display
$services_config[] = $session_service;
}
}
}
// Merge session URLs into loaded services
foreach ($services_config as $idx => $service) {
$service_name = $service['name'];
if (isset($_SESSION['pc_service_urls'][$service_name]) && !empty($_SESSION['pc_service_urls'][$service_name])) {
// Merge session URLs with config URLs (avoid duplicates)
$config_urls = isset($service['urls']) ? (array)$service['urls'] : array();
$session_urls = (array)$_SESSION['pc_service_urls'][$service_name];
$merged = array_unique(array_merge($config_urls, $session_urls));
$services_config[$idx]['urls'] = array_values($merged); // Reindex array
// BUGFIX v1.4.33: Also restore session URL statuses if they exist
if (isset($_SESSION['pc_service_url_status'][$service_name])) {
if (!isset($services_config[$idx]['url_status'])) {
$services_config[$idx]['url_status'] = array();
}
$services_config[$idx]['url_status'] = array_merge(
$services_config[$idx]['url_status'],
$_SESSION['pc_service_url_status'][$service_name]
);
}
}
}
// Handle form submissions
if ($_POST) {
if (isset($_POST['action'])) {
switch ($_POST['action']) {
case 'add_service':
$service_name = trim($_POST['service_name']);
if (!empty($service_name)) {
$existing = pc_find_service_by_name($services_config, $service_name);
if ($existing === null) {
// BUGFIX v1.4.34: Store new service in session instead of config
// PROBLEM: Writing to config.xml corrupts the file, causing pfSense to restore from backup
// SOLUTION: Store in session until "Monitor & Block" is clicked (same as URLs)
$new_service = array(
'name' => $service_name,
'urls' => array(trim($_POST['service_url'])),
'description' => trim($_POST['service_description']),
'icon' => 'fa-globe',
'enabled' => 'on',
'last_update' => 0,
'ip_count' => 0
);
// Add to session storage
$_SESSION['pc_new_services'][] = $new_service;
// Also add to in-memory $services_config for current request
$services_config[] = $new_service;
// Explicitly save session
session_write_close();
session_start();
pc_log("Added new online service to session: {$service_name}", 'info');
$savemsg = "Service '{$service_name}' added for this session. Click [Verify] to test URLs, then [Monitor&Block] to make it permanent.";
} else {
$savemsg = "Service '{$service_name}' already exists.";
}
}
break;
case 'delete_service':
$service_name = $_POST['service_name'];
// BUGFIX v1.4.34: Check if service is in session storage first
$deleted_from_session = false;
if (!empty($_SESSION['pc_new_services'])) {
foreach ($_SESSION['pc_new_services'] as $idx => $session_service) {
if ($session_service['name'] === $service_name) {
unset($_SESSION['pc_new_services'][$idx]);
$_SESSION['pc_new_services'] = array_values($_SESSION['pc_new_services']); // Reindex
$deleted_from_session = true;
session_write_close();
session_start();
error_log("Deleted service '{$service_name}' from session");
break;
}
}
}
// Also clean up session URLs and statuses for this service
if (isset($_SESSION['pc_service_urls'][$service_name])) {
unset($_SESSION['pc_service_urls'][$service_name]);
}
if (isset($_SESSION['pc_service_url_status'][$service_name])) {
unset($_SESSION['pc_service_url_status'][$service_name]);
}
// Try to delete from config (if it exists there)
$config_services = config_get_path('installedpackages/parentalcontrolservices/config', array());
$found_in_config = false;
foreach ($config_services as $idx => $cfg_service) {
if ($cfg_service['name'] === $service_name) {
$found_in_config = true;
break;
}
}
if ($found_in_config && remove_service_by_name($services_config, $service_name)) {
// BUGFIX v1.4.42: TRUE atomic service deletion with single config write
// PROBLEM: v1.4.41 had TWO writes - delete aliases, then recreate rules
// SOLUTION: Prepare ALL changes in memory, then ONE write saves everything
// Step 1: Remove service from config (in memory)
// Step 2: Delete aliases (in memory)
// Step 3: Rebuild rules WITHOUT deleted service (in memory)
// Step 4: Write config ONCE (atomic transaction saves all 3 changes)
// Step 5: Reload filter
// Delete associated pfSense aliases (prepares deletion in memory, doesn't write)
$aliases_deleted = pc_delete_service_alias($service_name);
error_log("Prepared alias deletion for: {$service_name} (deleted: " . ($aliases_deleted ? 'yes' : 'no') . ")");
// Update service config in memory
config_set_path('installedpackages/parentalcontrolservices/config', $services_config);
error_log("Prepared service config update (service removed from array)");
// Rebuild service monitoring rules WITHOUT deleted service (in memory, doesn't write)
require_once("/usr/local/pkg/parental_control.inc");
if (function_exists('pc_create_service_monitoring_rules')) {
pc_create_service_monitoring_rules();
error_log("Prepared service monitoring rules (rules rebuilt without {$service_name})");
}
// Single atomic write for service + aliases + rules
if (safe_write_config("Deleted service, aliases, and rules for: {$service_name}")) {
error_log("Config written successfully - all changes persisted atomically");
// Reload filter to apply rule changes
require_once("/etc/inc/filter.inc");
filter_configure();
error_log("Filter reloaded after service deletion: {$service_name}");
pc_log("Deleted online service from config: {$service_name}", 'info');
$savemsg = "Service '{$service_name}' deleted successfully. Service, aliases, and rules removed.";
} else {
$input_errors[] = "Failed to save config after deleting service '{$service_name}'.";
error_log("ERROR: Failed to write config after service deletion: {$service_name}");
}
} elseif ($deleted_from_session) {
$savemsg = "Service '{$service_name}' removed from session (was not yet saved to config).";
} else {
$input_errors[] = "Service '{$service_name}' not found.";
}
break;
case 'add_url':
$service_name = $_POST['service_name'];
$new_url = trim($_POST['new_url']);
if (!empty($new_url)) {
$found = pc_find_service_by_name($services_config, $service_name);
if ($found !== null) {
// CRITICAL FIX v1.4.10+: Store URL in PHP session for persistence
// Initialize session storage for this service
if (!isset($_SESSION['pc_service_urls'][$service_name])) {
$_SESSION['pc_service_urls'][$service_name] = array();
}
// Check if URL already exists (in config or session)
$existing_urls = isset($found['service']['urls']) ? (array)$found['service']['urls'] : array();
$all_urls = array_merge($existing_urls, $_SESSION['pc_service_urls'][$service_name]);
if (!in_array($new_url, $all_urls)) {
// Add to session storage
$_SESSION['pc_service_urls'][$service_name][] = $new_url;
// BUGFIX v1.4.33: Explicitly save session to ensure persistence
session_write_close();
session_start(); // Restart session for subsequent operations
// Also update in-memory config for current request
if (!isset($services_config[$found['index']]['urls'])) {
$services_config[$found['index']]['urls'] = array();
}
$services_config[$found['index']]['urls'][] = $new_url;
// NOTE: Not saving to config to avoid corruption
// URLs are session-based until alias is created
$savemsg = "URL added to '{$service_name}' for this session. Click [Verify] to check, then [Monitor&Block] to make it permanent.";
error_log("Added URL to {$service_name} (session storage): {$new_url}");
} else {
$savemsg = "URL already exists in '{$service_name}'.";
}
}
}
break;
case 'delete_url':
$service_name = $_POST['service_name'];
$url_index = intval($_POST['url_index']);
$found = pc_find_service_by_name($services_config, $service_name);
if ($found !== null && isset($found['service']['urls'][$url_index])) {
$deleted_url = $found['service']['urls'][$url_index];
// CRITICAL FIX v1.4.10+: Also remove from session storage
if (isset($_SESSION['pc_service_urls'][$service_name])) {
$key = array_search($deleted_url, $_SESSION['pc_service_urls'][$service_name]);
if ($key !== false) {
unset($_SESSION['pc_service_urls'][$service_name][$key]);
$_SESSION['pc_service_urls'][$service_name] = array_values($_SESSION['pc_service_urls'][$service_name]); // Reindex
// BUGFIX v1.4.33: Explicitly save session
session_write_close();
session_start();
}
}
// Remove the URL
array_splice($found['service']['urls'], $url_index, 1);
// Also remove the status for this URL
if (isset($found['service']['url_status'])) {