-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathmain.js
More file actions
7605 lines (7272 loc) · 436 KB
/
Copy pathmain.js
File metadata and controls
7605 lines (7272 loc) · 436 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
const WebSocket = require('ws');
const { app, BrowserWindow, ipcMain, shell, session } = require('electron');
const { exec, execSync, execFile, fork, spawn, spawnSync } = require('child_process');
const path = require('path');
const fs = require('fs');
const os = require('os');
const crypto = require('crypto');
// ════════════════════════════════════════════════════════════
// LOCAL ENGINE MODULES
//
// socks-fetch -- HTTP(S) over SOCKS5 on Node's own sockets, so exit
// verification can never silently no-op the way the old
// curl.exe shell-out did.
// tor-control -- Tor ControlPort client: re-pin the exit in ~1 s
// instead of restarting Tor and wiping its consensus
// cache (which took far longer than the old 12 s wait,
// so verification always ran against a Tor that was
// still bootstrapping).
// exit-selector-- pick ONE exit relay per country and remember it, the
// way a commercial VPN reuses a named server.
// ════════════════════════════════════════════════════════════
const { socksGet, directGet } = require('./lib/socks-fetch');
const { TorControl } = require('./lib/tor-control');
const { ExitStore, RelayIndex, probeExitLocation } = require('./lib/exit-selector');
const { GeoSpoof } = require('./lib/geo-spoof');
const { GeoExt } = require('./lib/geo-ext');
const browsers = require('./lib/browsers');
// What the installer and the uninstaller run inside this same exe, so
// NSIS never holds a second copy of a registry path or a browser list.
const installerTasks = require('./lib/installer-tasks');
// Where the user actually is. Asked from HERE rather than from the window, so
// that no remote host has to be named in index.html's connect-src -- and so
// that the kill switch can refuse the question outright. See the module head.
const { lookupHomeLocation } = require('./lib/home-location');
// v2.0.5, the two halves of "whole machine" that v2.0.0 did not have:
// Containment is default-deny outbound, so nothing can leave except through
// the tunnel; Tunnel is Wintun + tun2socks, so applications that have never
// heard of a proxy have their TCP carried by Tor anyway. Neither replaces the
// other -- one stops leaks, the other provides coverage. Read the head of
// each module for exactly what it can and cannot do.
const { Containment, RECOVERY_LNK } = require('./lib/containment');
const { Tunnel, TUN_NAME } = require('./lib/tunnel');
// The state directory this app executes out of. Measured: it inherits
// C:\ProgramData's ACL, which lets any local user drop a file in it -- and
// this app runs elevated and runs tor.exe and eight .bat files from there.
// See the head of the module; it is a privilege escalation, not untidiness.
const { secureStateDir } = require('./lib/state-dir');
// ════════════════════════════════════════════════════════════
// LOGGER (ASCII-only output — no Unicode, no garbled chars)
//
// Fix: Windows terminal (CP1252) garbles UTF-8 characters
// like === and —. Replaced with plain ASCII equivalents.
// ════════════════════════════════════════════════════════════
const Logger = (() => {
let logDir = '', logFile = '';
function init(ud) {
logDir = path.join(ud, 'logs');
// Guarded, because this is the FIRST thing whenReady() does and it now
// runs against a directory that lib/state-dir.js has locked down to
// administrators. The unelevated bootstrapper -- the copy whose only
// job is to relaunch this exe with RunAs -- reaches here too, and a
// throw would become an unhandled rejection inside whenReady().then()
// with the elevation never requested: the app would appear to start
// and then do nothing at all. write() already tolerates a log file it
// cannot append to, so losing the file costs those three handover
// lines and nothing else; the elevated copy writes its own.
try {
if (!fs.existsSync(logDir)) fs.mkdirSync(logDir, { recursive: true });
} catch (e) {
logDir = ''; logFile = '';
try { console.error(`log directory unavailable (${e.message}) -- ` +
'this run logs to the console only'); } catch (e2) {}
return;
}
rotateLogs();
logFile = path.join(logDir, `freeproxy-${dateStr()}.log`);
write('INFO', '======================================');
write('INFO', `FreeProxy VPN started -- PID ${process.pid}`);
write('INFO', `Platform: ${os.type()} ${os.release()} | Arch: ${os.arch()}`);
write('INFO', `Electron: ${process.versions.electron} Node: ${process.versions.node}`);
// Every stamp below is UTC, because toISOString() is. Windows itself is
// not: schtasks, Event Viewer and file mtimes all print local time. On a
// machine that is not on UTC those two clocks are read side by side
// during exactly the diagnosis this log exists for -- "the boot task and
// the logon task started two seconds apart" is a cross-clock claim -- so
// the offset is stated once, here, rather than left to be inferred.
write('INFO', `Timestamps below are UTC. This machine is ${tzOffsetStr()} ` +
`(local time now ${new Date().toLocaleString()})`);
write('INFO', '======================================');
}
function dateStr() { return new Date().toISOString().slice(0, 10); }
function timeStr() { return new Date().toISOString().replace('T', ' ').slice(0, 23); }
// "UTC+06:00" / "UTC-04:30". getTimezoneOffset() is minutes to ADD to local
// to get UTC, so its sign is the reverse of how offsets are written.
function tzOffsetStr() {
const m = -new Date().getTimezoneOffset();
const p = n => String(Math.abs(n)).padStart(2, '0');
return `UTC${m < 0 ? '-' : '+'}${p(Math.trunc(m / 60))}:${p(m % 60)}`;
}
function rotateLogs() {
try {
const files = fs.readdirSync(logDir)
.filter(f => f.startsWith('freeproxy-') && f.endsWith('.log')).sort();
while (files.length > 7) fs.unlinkSync(path.join(logDir, files.shift()));
} catch(e) {}
}
function write(level, message, meta = null) {
const ts = timeStr();
const pad = level.padEnd(7);
const ms = meta ? ' ' + JSON.stringify(meta) : '';
const line = `[${ts}] [${pad}] ${message}${ms}\n`;
// ASCII colour codes (safe on all Windows terminals)
const colours = {
DEBUG: '\x1b[90m', INFO: '\x1b[37m',
WARN: '\x1b[33m', ERROR: '\x1b[31m', SUCCESS: '\x1b[32m'
};
process.stdout.write((colours[level] || '') + line + '\x1b[0m');
if (logFile) { try { fs.appendFileSync(logFile, line); } catch(e) {} }
// Rolls the file over at midnight. Skipped when there is no log
// directory at all -- path.join('', name) is a RELATIVE path, and
// writing it would drop a log file in whatever the current working
// directory happens to be.
if (!logDir) return;
const nf = path.join(logDir, `freeproxy-${dateStr()}.log`);
if (nf !== logFile) logFile = nf;
}
function tail(n = 300, level = 'ALL') {
try {
const content = fs.existsSync(logFile) ? fs.readFileSync(logFile, 'utf8') : '';
let lines = content.split('\n').filter(Boolean);
if (level !== 'ALL') lines = lines.filter(l => l.includes(`[${level}`));
return lines.slice(-n);
} catch(e) { return []; }
}
return {
init,
getLogFile: () => logFile,
getLogDir: () => logDir,
tail,
debug: (m, x) => write('DEBUG', m, x),
info: (m, x) => write('INFO', m, x),
warn: (m, x) => write('WARN', m, x),
error: (m, x) => write('ERROR', m, x),
success: (m, x) => write('SUCCESS', m, x),
};
})();
// ════════════════════════════════════════════════════════════
// GEOLOCATION COORDS (capital cities per country)
// ════════════════════════════════════════════════════════════
const GEO_COORDS = {
'us':{ lat:38.8951, lng:-77.0364, accuracy:15, city:'Washington D.C.' },
'gb':{ lat:51.5074, lng:-0.1278, accuracy:12, city:'London' },
'ca':{ lat:45.4215, lng:-75.6919, accuracy:14, city:'Ottawa' },
'au':{ lat:-35.2809, lng:149.1300, accuracy:16, city:'Canberra' },
'de':{ lat:52.5200, lng:13.4050, accuracy:10, city:'Berlin' },
'fr':{ lat:48.8566, lng:2.3522, accuracy:11, city:'Paris' },
'nl':{ lat:52.3676, lng:4.9041, accuracy:10, city:'Amsterdam' },
'it':{ lat:41.9028, lng:12.4964, accuracy:13, city:'Rome' },
'es':{ lat:40.4168, lng:-3.7038, accuracy:12, city:'Madrid' },
'ch':{ lat:46.9481, lng:7.4474, accuracy:10, city:'Bern' },
'se':{ lat:59.3293, lng:18.0686, accuracy:12, city:'Stockholm' },
'no':{ lat:59.9139, lng:10.7522, accuracy:11, city:'Oslo' },
'dk':{ lat:55.6761, lng:12.5683, accuracy:10, city:'Copenhagen' },
'fi':{ lat:60.1699, lng:24.9384, accuracy:13, city:'Helsinki' },
'pl':{ lat:52.2297, lng:21.0122, accuracy:14, city:'Warsaw' },
'ro':{ lat:44.4268, lng:26.1025, accuracy:15, city:'Bucharest' },
'at':{ lat:48.2082, lng:16.3738, accuracy:11, city:'Vienna' },
'be':{ lat:50.8503, lng:4.3517, accuracy:10, city:'Brussels' },
'cz':{ lat:50.0755, lng:14.4378, accuracy:12, city:'Prague' },
'hu':{ lat:47.4979, lng:19.0402, accuracy:13, city:'Budapest' },
'pt':{ lat:38.7169, lng:-9.1399, accuracy:12, city:'Lisbon' },
'gr':{ lat:37.9838, lng:23.7275, accuracy:14, city:'Athens' },
'ie':{ lat:53.3498, lng:-6.2603, accuracy:11, city:'Dublin' },
'lu':{ lat:49.6117, lng:6.1319, accuracy:10, city:'Luxembourg City' },
'ru':{ lat:55.7558, lng:37.6173, accuracy:20, city:'Moscow' },
'ua':{ lat:50.4501, lng:30.5234, accuracy:18, city:'Kyiv' },
'jp':{ lat:35.6762, lng:139.6503, accuracy:12, city:'Tokyo' },
'kr':{ lat:37.5665, lng:126.9780, accuracy:11, city:'Seoul' },
'cn':{ lat:39.9042, lng:116.4074, accuracy:25, city:'Beijing' },
'sg':{ lat:1.3521, lng:103.8198, accuracy:10, city:'Singapore' },
'in':{ lat:28.6139, lng:77.2090, accuracy:22, city:'New Delhi' },
'bd':{ lat:23.8103, lng:90.4125, accuracy:18, city:'Dhaka' },
'pk':{ lat:33.7294, lng:73.0931, accuracy:20, city:'Islamabad' },
'ae':{ lat:24.4539, lng:54.3773, accuracy:12, city:'Abu Dhabi' },
'sa':{ lat:24.6877, lng:46.7219, accuracy:14, city:'Riyadh' },
'qa':{ lat:25.2854, lng:51.5310, accuracy:11, city:'Doha' },
'kw':{ lat:29.3759, lng:47.9774, accuracy:12, city:'Kuwait City' },
'om':{ lat:23.5880, lng:58.3829, accuracy:13, city:'Muscat' },
'il':{ lat:31.7683, lng:35.2137, accuracy:12, city:'Jerusalem' },
'tr':{ lat:39.9334, lng:32.8597, accuracy:15, city:'Ankara' },
'id':{ lat:-6.2088, lng:106.8456, accuracy:18, city:'Jakarta' },
'my':{ lat:3.1390, lng:101.6869, accuracy:13, city:'Kuala Lumpur' },
'th':{ lat:13.7563, lng:100.5018, accuracy:14, city:'Bangkok' },
'vn':{ lat:21.0285, lng:105.8542, accuracy:16, city:'Hanoi' },
'ph':{ lat:14.5995, lng:120.9842, accuracy:15, city:'Manila' },
'hk':{ lat:22.3193, lng:114.1694, accuracy:11, city:'Hong Kong' },
'tw':{ lat:25.0330, lng:121.5654, accuracy:12, city:'Taipei' },
'za':{ lat:-25.7479, lng:28.2293, accuracy:18, city:'Pretoria' },
'eg':{ lat:30.0444, lng:31.2357, accuracy:16, city:'Cairo' },
'ng':{ lat:9.0579, lng:7.4951, accuracy:22, city:'Abuja' },
'ke':{ lat:-1.2921, lng:36.8219, accuracy:17, city:'Nairobi' },
'ma':{ lat:33.9716, lng:-6.8498, accuracy:15, city:'Rabat' },
'tn':{ lat:36.8065, lng:10.1815, accuracy:15, city:'Tunis' },
'br':{ lat:-15.7801, lng:-47.9292, accuracy:20, city:'Brasilia' },
'ar':{ lat:-34.6037, lng:-58.3816, accuracy:16, city:'Buenos Aires' },
'mx':{ lat:19.4326, lng:-99.1332, accuracy:18, city:'Mexico City' },
'co':{ lat:4.7110, lng:-74.0721, accuracy:17, city:'Bogota' },
'cl':{ lat:-33.4489, lng:-70.6693, accuracy:14, city:'Santiago' },
'nz':{ lat:-41.2865, lng:174.7762, accuracy:13, city:'Wellington' },
'is':{ lat:64.1355, lng:-21.8954, accuracy:12, city:'Reykjavik' },
'kz':{ lat:51.1801, lng:71.4460, accuracy:22, city:'Nur-Sultan' },
'hr':{ lat:45.8150, lng:15.9819, accuracy:13, city:'Zagreb' },
'bg':{ lat:42.6977, lng:23.3219, accuracy:14, city:'Sofia' },
'md':{ lat:47.0105, lng:28.8638, accuracy:15, city:'Chisinau' },
'rs':{ lat:44.7866, lng:20.4489, accuracy:14, city:'Belgrade' },
'lt':{ lat:54.6872, lng:25.2797, accuracy:12, city:'Vilnius' },
'lv':{ lat:56.9496, lng:24.1052, accuracy:12, city:'Riga' },
'ee':{ lat:59.4370, lng:24.7536, accuracy:12, city:'Tallinn' },
'cy':{ lat:35.1856, lng:33.3823, accuracy:13, city:'Nicosia' },
'az':{ lat:40.4093, lng:49.8671, accuracy:16, city:'Baku' },
'ge':{ lat:41.7151, lng:44.8271, accuracy:15, city:'Tbilisi' },
'pe':{ lat:-12.0464, lng:-77.0428, accuracy:17, city:'Lima' },
'cr':{ lat:9.9281, lng:-84.0907, accuracy:14, city:'San Jose' },
'sc':{ lat:-4.6191, lng:55.4513, accuracy:12, city:'Victoria' },
};
// ── the ONE way to read that table ──────────────────────────────────
// `GEO_COORDS[cc]` was read directly in eight places, all of them gated on
// the truthiness of the result, and two strings get past a gate like that
// without being countries: 'constructor' and '__proto__'. Both survive
// .toLowerCase() unchanged, both resolve on Object.prototype, and both are
// truthy -- so `if (!coord) return` passes, and then coord.lat is undefined.
// What that produced downstream: a CDP geolocation override of NaN, a toast
// reading "undefined, CONSTRUCTOR", and the same undefined coordinates handed
// to the browser extension over the WebSocket.
//
// Reachable without any injection at all: settings.json is loaded with
// `typeof s.serverCode === 'string'` as its only check, so a state file
// holding "__proto__" -- which any process running as this user can write --
// is enough.
//
// hasOwnProperty via Object.prototype.call, not coord.hasOwnProperty: the
// table is a plain object literal here, but calling a method THROUGH the
// object being validated is the same mistake one level up.
//
// The two-letter test is not redundant with it. It is what keeps this
// function's contract the same as isCc() in lib/exit-selector.js and
// ccName() in renderer.js, so "a country code" means one thing everywhere in
// the app.
function geoCoord(cc) {
// A string, not something that STRINGIFIES to one. `['us']` coerces to
// 'us' through String(), so an array is otherwise a country here -- and
// settings.json is JSON, where an array is one keystroke away from a
// string. Nothing in this app calls geoCoord with anything but a string,
// so requiring one costs nothing and makes the contract exact.
if (typeof cc !== 'string') return null;
const k = cc.toLowerCase();
if (!/^[a-z]{2}$/.test(k)) return null;
return Object.prototype.hasOwnProperty.call(GEO_COORDS, k) ? GEO_COORDS[k] : null;
}
// Same table, asked as a question. Used where only the yes/no matters.
const isSpoofableCc = cc => geoCoord(cc) !== null;
// ════════════════════════════════════════════════════════════
// GEOLOCATION SPOOF ENGINE
// ════════════════════════════════════════════════════════════
let geoSpoofActive = false;
// Drop any country the app cannot spoof a location for.
//
// Onionoo decides which countries have exit relays, and that set
// changes as relays come and go -- so it will eventually contain a
// country GEO_COORDS has never heard of. Offering it would produce
// the worst outcome available: the tunnel comes up, the IP changes,
// and the page still reads the real position because
// applyGeolocationSpoof has nothing to apply. Refusing to list the
// country is the safe direction to fail in.
function spoofableOnly(stats) {
const out = {};
const dropped = [];
for (const [cc, v] of Object.entries(stats)) {
if (isSpoofableCc(cc)) out[cc] = v; else dropped.push(cc);
}
if (dropped.length) {
Logger.warn(`Hiding ${dropped.length} exit country/ies with no coordinates: ` +
dropped.join(', '));
}
return out;
}
// ════════════════════════════════════════════════════════════
// HOW CLOSE IS CLOSE? -- the ordering behind the "connect me
// somewhere near instead" option
//
// When the country the user picked has no exit relay, the app offers to
// connect to the NEAREST country that has one. "Nearest" has to mean
// something measurable, so it is the great-circle distance between the two
// capitals in GEO_COORDS -- the same table the geolocation spoof reads, so
// the distance the choice was made on is the distance between the two
// positions the app would actually report.
//
// Capitals, not centroids or borders: a border-to-border distance would call
// Russia the closest country to Norway, and the position this app spoofs is
// the capital, not the border. It is an approximation and it is named as one
// -- but it is an approximation of the right thing, and it never guesses:
// every country it can return is one the live relay index says has an exit.
// ════════════════════════════════════════════════════════════
function haversineKm(a, b) {
const R = 6371;
const rad = d => d * Math.PI / 180;
const dLat = rad(b.lat - a.lat), dLng = rad(b.lng - a.lng);
const h = Math.sin(dLat / 2) ** 2 +
Math.cos(rad(a.lat)) * Math.cos(rad(b.lat)) * Math.sin(dLng / 2) ** 2;
return 2 * R * Math.asin(Math.min(1, Math.sqrt(h)));
}
// Every country in `stats` that is not `cc` and not excluded, nearest first.
// `stats` is the live exit-relay index (countryStats()), so a country only
// appears here if it currently HAS exit capacity -- offering a neighbour with
// no exits would just move the same failure one country sideways.
function nearestExitCountries(cc, stats, { exclude = [] } = {}) {
const home = geoCoord(cc);
if (!home) return [];
const skip = new Set([cc, ...exclude]);
return Object.keys(stats || {})
.map(k => ({ k, c: geoCoord(k) }))
.filter(({ k, c }) => !skip.has(k) && c && (stats[k]?.count || 0) > 0)
.map(({ k, c }) => ({ cc: k, km: Math.round(haversineKm(home, c)) }))
.sort((a, b) => a.km - b.km);
}
// One engine per process, built lazily -- APPDATA_PATH is not assigned
// until further down this file.
let _geoEngine = null;
function geoEngine() {
if (!_geoEngine) _geoEngine = new GeoSpoof({ log: Logger, stateDir: APPDATA_PATH });
return _geoEngine;
}
// The Chromium delivery layer: packages Extension/, serves it from
// loopback and force-installs it wherever that route is accepted. Kept
// separate from GeoSpoof deliberately -- one owns the user's browsers,
// the other owns the Windows platform and Firefox, and they fail
// independently. A browser without the extension must not stop lfsvc
// from being denied, and vice versa.
let _geoExt = null;
function geoExt() {
if (!_geoExt) _geoExt = new GeoExt({
log: Logger, stateDir: APPDATA_PATH,
// Extension/ ships as an extraResource, not inside the asar, so the
// packaged path is resourcesPath -- __dirname would point into
// app.asar where it is not present at all.
sourceDir: app.isPackaged
? path.join(process.resourcesPath, 'Extension')
: path.join(__dirname, 'Extension'),
});
return _geoExt;
}
// Report what is covered, per surface, and in WHICH SENSE.
//
// The distinction matters and is not padding, and each word here is a
// measurement rather than a hope.
//
// "Spoofed" means the API hands back the connected country's coordinates.
// That is true of this app's own window, of Firefox, and of every Chromium
// profile where presence() can see the extension actually loaded.
//
// "Shielded" means something weaker and is never called a spoof. The Windows
// location platform -- Maps, Weather, and every Win32/WinRT app that calls
// the Geolocator API -- cannot be handed fake coordinates at all. That was
// measured, not assumed: .build/test-winloc-default.js writes Windows' own
// documented Default Location and a native .NET consumer keeps reporting the
// real position, with the survey working AND with lfsvc cut off from the
// network and restarted. There is no location sensor on the machine to
// deliver that fallback, and the only two mechanisms that would work -- a
// signed virtual GPS driver, or injecting into every process that asks --
// are refused on purpose, the first because shipping it unsigned means
// asking the user to disable driver signature enforcement, the second
// because it is indistinguishable from a rootkit.
//
// So what the app does for that surface is cut the LEAK and leave the
// SETTING alone: one named, service-scoped firewall rule stops lfsvc from
// reaching Microsoft's location service, so no fresh real fix can be
// resolved or sent while connected. Location stays ON, permission stays
// Granted, and the user's Settings control stays theirs -- an earlier build
// denied instead, and switching a user's own location off underneath them
// is what that cost. A native app holding a cached fix can still report it;
// that is the honest ceiling and it is what gets logged.
//
// Browser coverage is DETECTED, never assumed, and every browser named in
// this report comes from lib/browsers.js. presence() reads each installed
// fork's OWN profile and answers whether the extension is genuinely loaded
// AND enabled there; the Gecko count is the number of profiles this run
// actually wrote. No browser is ever named as covered because the table says
// it could be -- the table only says where to look.
//
// 'needs-enable' is its own group and is never folded into the spoofed one.
// Measured: with the delivery helper serving the CRX the policies name, Edge
// installs it at location 7 with no disable reason and starts its service
// worker, while Chrome and Brave unpack the same bytes at location 6 and
// record disable_reasons [8192] = EXTERNAL_EXTENSION -- present, and switched
// off until the user accepts it. Those two are not spoofing anything yet, and
// a report that said they were would be the exact kind of claim this function
// exists to avoid.
// ── What the browsers themselves say ────────────────────────────────
// An installed, enabled extension is not a spoofing extension. Its service
// worker has to be RUNNING and holding an active record, and only the worker
// knows that -- so it says so, on the socket it already has, and this is where
// those reports are kept. Extension/background.js: reportGeoState().
//
// Keyed by the socket, because that is the only identity available: every
// browser is running the same extension id, so a report cannot name its
// browser and nothing here pretends otherwise. What it can say honestly is how
// many live workers have confirmed the spoof, which is what the log and the
// connect toast now use in place of "installed, therefore spoofed".
//
// Every connected extension socket gets an entry, so `size` is the number of
// browser profiles talking to the app and the armed ones are a subset of it.
const geoLive = new Map();
function noteGeoClient(ws) {
geoLive.set(ws, { armed: false, cc: '', at: Date.now() });
}
// Every extension id that is legitimately ours, for the HELLO check. knownId()
// rather than the packaged id alone: a HELLO can arrive before prepare() has
// packaged anything, and the journal is what remembers the id across a restart.
// Returns empty when nothing is known, and an empty list stands nobody down.
function ourExtensionIds() {
const out = [];
try {
const e = geoExt();
for (const v of [e.knownId(), e.edgeStoreId, e.webstoreId]) {
if (typeof v === 'string' && /^[a-p]{32}$/.test(v) && !out.includes(v)) out.push(v);
}
} catch (err) { /* nothing known yet: refuse nothing */ }
return out;
}
function noteGeoState(ws, d) {
geoLive.set(ws, {
armed: d.armed === true,
cc: typeof d.cc === 'string' ? d.cc.toUpperCase() : '',
at: Date.now(),
});
}
// Confirmations for the country the app is connected to, and only that country:
// a worker still holding the previous one is not covering this connection.
function geoConfirmed(cc) {
const want = String(cc || '').toUpperCase();
let n = 0;
for (const v of geoLive.values()) {
if (v.armed && (!want || v.cc === want)) n++;
}
return n;
}
function reportGeoCoverage(coord, cc) {
const s = geoEngine().status();
const where = coord ? coord.city : 'the connected country';
// presence() answers per browser ID; every id becomes a display name
// before it reaches a log line, so the user reads "Microsoft Edge" and
// not "edge".
let seen = {};
try { seen = geoExt().presence(); } catch (e) {}
const withState = st => browsers.names(Object.keys(seen).filter(b => seen[b] === st));
const covered = withState('installed');
const pending = withState('needs-enable');
const declined = withState('declined');
// 'absent' is two different situations and only one of them is the user's
// problem. MEASURED: a browser that was already open when the external-
// extensions entry was written never sees it -- Chrome, open since before,
// had nothing hours later -- while Brave, started 12 minutes after, had it
// 3 minutes into that start. So an absent browser with the route still
// armed is waiting for a start, not for the user to load a folder by hand,
// and saying otherwise invents work.
let armed = [];
try { armed = geoExt().awaitingStart(); } catch (e) {}
const waiting = browsers.names(armed);
const missing = withState('absent').filter(n => !waiting.includes(n));
// Gecko is reported by the fork whose profiles were actually written,
// never as "Firefox" for whatever happened to be on disk. A profile
// directory left behind by an uninstalled browser is not spoofed and is
// not counted -- claiming it was is exactly what the verified-executable
// check in lib/browsers.js exists to prevent.
const geckoHere = browsers.names(browsers.detectGecko().map(b => b.id));
const gecko = s.geckoSpoofed
? `${(s.geckoBrowsers || ['Gecko']).join('/')}: spoofed (${where})`
: geckoHere.length
? `${geckoHere.join('/')}: installed but NOT spoofed yet`
: 'Gecko family: not installed';
// INSTALLED is not SPOOFING, and conflating the two is what the user's
// screenshot caught: Brave was enabled, counted as covered, and handing
// Google Maps the device's real position because its service worker had
// been evicted and never woken. So the two facts are printed separately --
// what is on disk, from presence(), and what the browsers themselves have
// confirmed over the socket, from geoConfirmed().
const live = geoConfirmed(cc);
const talking = geoLive.size;
const confirmed = live
? `${live} of ${talking} connected browser profile(s) CONFIRMED the spoof is ` +
`armed for ${String(cc || '').toUpperCase() || 'this country'}`
: (talking
? `${talking} browser profile(s) are connected but NONE has confirmed the ` +
'spoof yet -- an enabled extension whose worker is not running is not ' +
'spoofing anything'
: 'no browser extension is connected right now, so nothing is confirmed ' +
'from the browser side');
Logger.info('Location coverage -- ' +
`app window: spoofed (${where}); ` +
`Chromium (${covered.join('/') || 'none'}): extension installed and enabled; ` +
`${confirmed}; ` +
(pending.length
? `Chromium (${pending.join('/')}): extension delivered but switched OFF, ` +
'so NOT spoofed there yet; ' : '') +
(declined.length
? `Chromium (${declined.join('/')}): the user removed the extension, not spoofed; ` : '') +
(waiting.length
? `Chromium (${waiting.join('/')}): set up, not picked up yet -- arrives at ` +
'that browser\'s next start or within ~2 h; ' : '') +
`${gecko}; ` +
`Windows platform: ${s.windowsShielded ? 'shielded -- lfsvc cannot resolve or ' +
'send a fresh real fix, so no native app is given ' + where + ' either: ' +
'Windows has no coordinate-injection API' : 'NOT shielded'}`);
// The REASON decides what can honestly be said next, so it is read rather
// than assumed. This used to say "one switch, once, and it is permanent"
// about every switched-off browser. That is true for exactly one reason --
// EXTERNAL_EXTENSION (8192), the prompt Chromium shows for anything an
// installer offered, which the user's acceptance clears for good. For the
// reasons the browser raises BY ITSELF (256 NOT_VERIFIED, 512 GREYLIST,
// 1048576 NOT_ALLOWLISTED, 1024 CORRUPTED, the unsupported-manifest and
// developer-extension ones) the same switch can be undone by the browser at
// its next enforcement pass, and promising permanence there is a claim this
// app cannot keep. ExtensionInstallAllowlist is written to pre-empt those,
// and whether it worked is read back here -- never assumed.
if (pending.length) {
let detail = {};
try { detail = geoExt().states(); } catch (e) {}
const pendingIds = Object.keys(seen).filter(b => seen[b] === 'needs-enable');
const byAuthor = new Map();
for (const id of pendingIds) {
const why = (detail[id] && detail[id].disabled) || [];
const author = browsers.disableAuthor(why) || 'unknown';
if (!byAuthor.has(author)) byAuthor.set(author, { ids: [], why: new Set() });
const g = byAuthor.get(author);
g.ids.push(id);
for (const w of why) g.why.add(w);
}
const signed = 'the record holding that bit is signed with the profile\'s own ' +
'key, so nothing this app writes can flip it';
for (const [author, g] of byAuthor) {
const who = browsers.names(g.ids).join(' and ');
const why = [...g.why].join(', ') || 'no reason recorded';
if (author === 'prompt') {
Logger.warn(`${who}: the extension is downloaded and unpacked, switched off ` +
`(${why}). Chromium keeps anything an installer offered disabled ` +
`until the user accepts it once, and ${signed}. One switch on the ` +
'extensions page, once, and it stays on for this reason -- until ' +
'then the real location is what those browsers report');
} else if (author === 'browser') {
Logger.warn(`${who}: the extension is in the profile and the BROWSER switched ` +
`it off by itself (${why}), not the user. ${signed}. ` +
'ExtensionInstallAllowlist is written for this browser to stop that ' +
'happening again; if it is still off after a restart, that policy ' +
'is not being honoured here and the honest answer is that this ' +
'browser reports the real location. Switching it on by hand works ' +
'until the browser\'s next check -- this app will not call that ' +
'permanent');
} else if (author === 'admin') {
Logger.warn(`${who}: an administrator policy on this machine has the extension ` +
`switched off (${why}). ${signed}, and this app does not overrule ` +
'a policy it did not write. Those browsers report the real location');
} else if (author === 'user') {
Logger.warn(`${who}: the extension is present and the user switched it off ` +
`(${why}). That is left exactly as it is -- the switch belongs to ` +
'the user. Those browsers report the real location until it is ' +
'switched back on');
} else {
Logger.warn(`${who}: the extension is present and switched off for a reason ` +
`this build does not have a name for (${why}). ${signed}. Reported ` +
'as not spoofed, because that is all that can be verified');
}
}
}
if (waiting.length) {
Logger.info(`${waiting.join(' and ')}: the extension is registered for ` +
(waiting.length > 1 ? 'them' : 'it') + ' and the package is being served, ' +
'and has not been picked up yet. A browser takes a registration like ' +
'this at its next start, or on its own within about two hours -- ' +
'measured on this machine: 3 min for a browser started afterwards, ' +
'93 to 108 min for two left running. So it arrives the next time ' +
(waiting.length > 1 ? 'those browsers are' : 'that browser is') +
' opened (the restart this app offers does that for all of them at ' +
'once), and needs one switch then. Nothing to do by hand.');
}
if (missing.length) {
Logger.warn(`${missing.join(' and ')} will keep reporting the real location until ` +
'the spoofer is loaded there once by hand -- instructions in ' +
geoExt().baseDir);
}
// Installed browsers with no extension model at all: Internet Explorer
// and the WebView/UWP hosts. Their traffic goes through the system proxy,
// so the exit country is right there, but their geolocation comes from the
// Windows platform -- which is shielded and cannot be spoofed. Naming them
// is the difference between a coverage report and a claim.
const noExt = browsers.names(browsers.detect().filter(b => b.family === 'wininet')
.map(b => b.id));
if (noExt.length) {
Logger.info(`${noExt.join(' and ')}: traffic is proxied so the exit country is ` +
'correct there, but its location comes from the Windows platform -- ' +
'shielded, never spoofed');
}
if (!s.windowsShielded) {
Logger.warn('The Windows location platform is not shielded -- lfsvc can still put ' +
"the user's real surroundings on the wire and a native app can read the " +
'real position. The firewall rule needs administrator rights.');
}
if (s.legacyGrantsPending) {
Logger.warn(`${s.legacyGrantsPending} site permission(s) that an older build set to ` +
'Block are still waiting to be handed back -- close every browser and ' +
'disconnect once to finish that.');
}
}
function applyGeolocationSpoof(win, serverCode) {
const coord = geoCoord(serverCode);
if (!coord) { Logger.warn('No geo coords for code', { serverCode }); return; }
// Set here rather than inside the CDP .then(): if attaching the
// debugger fails, the device-wide layers still ran and
// clearGeolocationSpoof must still tear them down. Flagging this only
// on CDP success is how a failed attach used to leave the machine
// with its location switched off after disconnecting.
geoSpoofActive = true;
const jitter = () => (Math.random() - 0.5) * 0.004;
const lat = coord.lat + jitter();
const lng = coord.lng + jitter();
// ── Layer 1: CDP override (Electron window) ─────────────────
try {
if (!win.webContents.debugger.isAttached()) {
win.webContents.debugger.attach('1.3');
}
win.webContents.debugger.sendCommand('Emulation.setGeolocationOverride', {
latitude: lat,
longitude: lng,
accuracy: coord.accuracy,
}).then(() => {
Logger.success(`GPS spoofed -> ${coord.city} (${serverCode.toUpperCase()})`);
}).catch(e => Logger.error('CDP geo override failed', { err: e.message }));
} catch(e) {
Logger.error('CDP debugger attach failed', { err: e.message });
}
// ── Layer 2: Notify renderer to patch navigator.geolocation ─
win.webContents.send('geo-spoof-on', {
lat, lng, accuracy: coord.accuracy, city: coord.city, country: serverCode.toUpperCase(),
});
// ── The device-wide layers ──────────────────────────────────
// The user's browsers, lfsvc and Firefox are handled by
// lib/geo-ext.js and lib/geo-spoof.js, driven from the wrapper at
// the bottom of this file. They live there for two reasons:
//
// * each change has to be RECORDED before it is made, so
// disconnecting restores what the user actually had. Stopping
// lfsvc from here, before that snapshot was taken, is what made
// the app record "the service was already stopped" and then never
// start it again.
// * packaging the extension, writing its install policy and waiting
// for the browsers to exit are all asynchronous, and cannot be
// awaited from a synchronous function.
}
function clearGeolocationSpoof(win) {
if (!geoSpoofActive) return;
try {
if (win.webContents.debugger.isAttached()) {
win.webContents.debugger.sendCommand('Emulation.clearGeolocationOverride')
.catch(e => Logger.warn('CDP geo clear failed', { err: e.message }));
}
} catch(e) {}
win.webContents.send('geo-spoof-off');
// The browser policies, the per-profile settings, the Windows
// location platform and Firefox are all restored by the wrapper at
// the bottom of this file, from the journal that recorded what each
// of them held BEFORE the connection. Undoing them here as well --
// from hard-coded values, with no backup -- is how "restore" used to
// hand the user settings they never had.
geoSpoofActive = false;
Logger.info('GPS spoof cleared -- real location restored');
}
// ════════════════════════════════════════════════════════════
// UAC CHECK
// ════════════════════════════════════════════════════════════
function isRunAsAdmin() {
try { execSync('net session', { stdio: 'ignore', windowsHide: true }); return true; }
catch(e) { return false; }
}
// A headless job never draws anything, and one of them -- --fp-boot -- runs as
// SYSTEM at startup, in a session with no interactive desktop and no GPU to
// talk to. Asking Chromium for hardware acceleration there is how a task that
// only writes registry values would end up failing on the one machine state it
// exists for. Both calls have to happen before app.whenReady(), so they sit
// here rather than next to the job itself.
if (installerTasks.installerTask(process.argv)) {
try {
app.disableHardwareAcceleration();
app.commandLine.appendSwitch('disable-gpu');
app.commandLine.appendSwitch('no-sandbox');
} catch (e) { /* an older Electron: the job still runs */ }
}
// Override userData to C:\ProgramData\freeproxy-vpn (no spaces in path)
// Must be set BEFORE app.getPath() is ever called
const APPDATA_PATH = 'C:\\ProgramData\\freeproxy-vpn';
// If this throws, userData silently stays at %APPDATA%\freeproxy-vpn -- and on
// this very machine that is C:\Users\User pc\..., a path with a space in it,
// under a profile a SYSTEM boot task cannot even see. Everything downstream
// then writes its state, its Tor bundle and its log somewhere the elevated half
// of the app will not find them, and the only symptom is that nothing works.
// It was being swallowed whole. Logger does not exist yet (Logger.init runs
// inside whenReady, from this very path), so the reason is kept here and
// logged the moment there is a log to write it to.
let userDataFallback = null;
try {
if (!require('fs').existsSync(APPDATA_PATH)) {
require('fs').mkdirSync(APPDATA_PATH, { recursive: true });
}
app.setPath('userData', APPDATA_PATH);
} catch (e) {
userDataFallback = e.message;
try { console.error(`FATAL: could not use ${APPDATA_PATH} -- ${e.message}`); } catch (e2) {}
}
// ── The delivery port, bound before Electron finishes starting ──
// MEASURED 2026-09-01: this task's action started at 21:37:27 and
// app.whenReady() did not fire until 21:38:09.5 -- 42.5 seconds of Chromium
// browser-process init, with the port every browser policy names dead for all
// of it. The user's desktop is usable well inside that window, and a browser
// started there reads its external-extensions provider once, finds nothing
// listening, installs nothing, and does not look again until its next start.
// That is the reported "restart er por Edge e dhukle kichu asena, kete diye
// abar open korle asche".
//
// Serving the two static files needs http, fs and the bundle on disk -- no
// window, no GPU, no Electron API -- and the main process's event loop is
// already running here. So the listener goes up now, seconds into the process,
// and serveUntilDelivered() adopts it (and replays the log lines it could not
// write yet, Logger.init being inside whenReady). A port already held by the
// boot pass still lands on the existing grace path, unchanged.
if (installerTasks.installerTask(process.argv) === 'deliver') {
try { require('./lib/ext-deliver').serveEarly({ stateDir: APPDATA_PATH }); }
catch (e) { /* whenReady still runs the job the ordinary way */ }
}
// ════════════════════════════════════════════════════════════
// STAYING ALIVE -- the four ways this process could die quietly
//
// None of these was handled before. Node's default for an uncaught exception
// in the main process is to print to stderr and exit(1) -- in a packaged app
// that is the window vanishing with nothing in the log, which is the reported
// "installation er por app crash". Electron's default for a dead renderer or
// a dead utility child is the opposite failure: the process lives on behind a
// blank window that answers nothing, which is the "hang".
//
// So each one is logged AND sent to the window. Nothing is swallowed: a fault
// the user cannot see is worse than one they can, and this app runs elevated.
let _reloadsAfterCrash = 0;
function reportFault(kind, detail, meta = null) {
try { Logger.error(`${kind}: ${detail}`, meta); } catch (e) {}
try { BrowserWindow.getAllWindows()[0]?.webContents?.send(
'app-fault', { kind, detail }); } catch (e) {}
}
const firstLines = e => (e && e.stack) ? String(e.stack).split('\n').slice(0, 6) : null;
process.on('uncaughtException', err => {
reportFault('Uncaught exception', (err && err.message) || String(err),
{ stack: firstLines(err) });
});
process.on('unhandledRejection', reason => {
reportFault('Unhandled rejection', (reason && reason.message) || String(reason),
{ stack: firstLines(reason) });
});
app.on('render-process-gone', (_e, _wc, d) => {
if (d.reason === 'clean-exit') return;
reportFault('Renderer gone', d.reason, { exitCode: d.exitCode });
// Bounded, because a renderer that crashes on load would otherwise
// reload forever. Two tries, then the window is left as it is with the
// reason already in the log rather than spun in a loop.
if (_reloadsAfterCrash++ < 2) {
try { BrowserWindow.getAllWindows()[0]?.reload(); } catch (e) {}
}
});
app.on('child-process-gone', (_e, d) => {
if (d.reason === 'clean-exit') return;
reportFault('Child process gone', `${d.type}: ${d.reason}`,
{ exitCode: d.exitCode, name: d.name || null });
});
app.whenReady().then(() => {
Logger.init(app.getPath('userData'));
Logger.info('app.whenReady() fired');
if (userDataFallback) {
Logger.error(`userData could NOT be set to ${APPDATA_PATH} -- using ` +
`${app.getPath('userData')} instead. The boot task and the ` +
`installer look in ${APPDATA_PATH}, so this build will not ` +
`find its own state.`, { err: userDataFallback });
}
// --fp-setup / --fp-teardown / --fp-boot: do that job and exit. No
// window, no Tor, and no elevation dance -- the installer is already
// elevated and the boot task runs as SYSTEM, and prompting from inside
// either would be a UAC dialog with no visible parent. Placed before the
// admin check for exactly that reason; lib/installer-tasks.js warns if it
// really is unelevated.
const installerJob = installerTasks.installerTask(process.argv);
if (installerJob) {
installerTasks.runInstallerTask(installerJob, {
Logger, isRunAsAdmin, geoEngine, geoExt,
stateDir: APPDATA_PATH,
restoreBrowserPolicy: restoreAllBrowsersProxy,
}).then(code => {
Logger.info(`--fp-${installerJob} finished with exit code ${code}`);
app.exit(code);
});
return;
}
if (!isRunAsAdmin()) {
Logger.warn('Not admin -- requesting elevation');
const ps1 = path.join(os.tmpdir(), 'vpn_elevate.ps1');
const exe = process.execPath, dir = __dirname;
const scr = app.isPackaged
? `Start-Process -FilePath '${exe}' -Verb RunAs -Wait`
: `Start-Process -FilePath '${exe}' -ArgumentList '"${dir}"' -Verb RunAs -Wait`;
fs.writeFileSync(ps1, scr);
const child = spawn('powershell.exe',
['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', ps1],
{ stdio: 'inherit' });
child.on('exit', code => {
Logger.info(`Elevation exited: ${code}`);
app.quit(); process.exit(code || 0);
});
} else {
Logger.success('Running with admin privileges');
// ── One instance ──
// Asked for HERE and nowhere else, deliberately. The unelevated
// bootstrapper just above spawns an elevated copy of this same exe
// against this same userData path; a lock taken before that hand-off
// would be held by the process that is about to exit, and the elevated
// copy -- the one that does all the work -- would be the one denied.
// So the lock belongs to the branch that actually runs the app.
//
// What it prevents is concrete: two elevated instances mean two
// firewall writers, two tor.exe fighting over :9050 and :8080, and two
// teardown paths racing to restore this machine's proxy on the way out.
// The installer/boot jobs above return before reaching here, so an
// uninstall teardown is never blocked by a running window.
if (!app.requestSingleInstanceLock()) {
Logger.warn('Another instance already holds the single-instance ' +
'lock -- focusing it and exiting.');
app.quit();
return;
}
app.on('second-instance', () => {
Logger.info('second-instance -- focusing the existing window');
const w = BrowserWindow.getAllWindows()[0];
if (!w) return;
if (w.isMinimized()) w.restore();
w.show(); w.focus();
});
runAdminApp();
}
});
// ════════════════════════════════════════════════════════════
// THE SPLIT-TUNNEL LIST, made safe for an elevated .bat
//
// Two separate faults, in a mapping that used to be written out twice (in
// the WinINET writer and again in update-live-bypass) and could drift.
//
// 1. INJECTION. This value is interpolated into a .bat that the app then
// runs elevated through cmd.exe. The old sanitiser stripped `*`,
// whitespace, a leading scheme and any path -- but not `"`, `&`, `|`,
// `^`, `%`, `<` or `>`. An entry of x" /f & <command> closes the reg
// quote and starts a second ELEVATED command. The string arrives from
// the UI textarea and from UPDATE_BYPASS over the WebSocket, so it is
// not trusted input. Fixed by an ALLOWLIST rather than escaping: a host
// is letters/digits/dot/hyphen, or an IPv4 literal. Anything else is
// dropped and named in the log. A character that means something to
// cmd.exe cannot survive to reach the file.
//
// 2. OVER-MATCH -- a leak, not just untidiness. `*entry*` also matched
// `notentry.attacker.example`, so a host the user never listed went
// DIRECT instead of through Tor. WinINET's own form for "this host and
// its subdomains" is `host;*.host`, which is what is emitted now.
//
// At MODULE scope, and there is one copy of it, because THREE writers need
// the identical answer: the WinINET .bat and netsh winhttp inside
// runAdminApp(), and Chromium's ProxyBypassList in
// forceAllBrowsersOntoProxy(), which is out here. A browser bypassing a
// host that Windows tunnels -- or the reverse -- is a split-tunnel list
// that does not mean what the user typed.
// ════════════════════════════════════════════════════════════
const BP_HOST = /^(?!-)[a-z0-9-]{1,63}(?:\.(?!-)[a-z0-9-]{1,63})*$/;
const BP_IPV4 = /^(?:(?:25[0-5]|2[0-4]\d|1?\d?\d)\.){3}(?:25[0-5]|2[0-4]\d|1?\d?\d)$/;
function bypassToProxyOverride(bypassList) {
const out = [], dropped = [];
for (const raw of String(bypassList || '').replace(/,/g, ';').split(';')) {
const e = raw.trim()
.replace(/^[a-z][a-z0-9+.-]*:\/\//i, '') // scheme
.replace(/\/.*$/, '') // path
.replace(/:\d+$/, '') // port
.replace(/^\*\./, '').replace(/\.$/, '') // leading *., trailing dot
.toLowerCase();
if (!e || e === '<local>') continue; // <local> added below
if (BP_IPV4.test(e)) { out.push(e); continue; }
if (e.length <= 253 && BP_HOST.test(e)) { out.push(e, `*.${e}`); continue; }
dropped.push(raw.trim().slice(0, 40));
}
if (dropped.length) {
Logger.warn('Split-tunnel entries dropped -- not a hostname or an IPv4 ' +
'address', { dropped });
}
// De-duplicated, so pressing Save twice cannot grow the registry value.
const uniq = [...new Set(out)];
return uniq.length ? uniq.join(';') + ';<local>' : '<local>';
}
// ════════════════════════════════════════════════════════════
// MAIN APP
// ════════════════════════════════════════════════════════════
function runAdminApp() {
const getScriptPath = f => path.join(app.getPath('userData'), f);
let torDir = '';
let mainWindow = null;
// ── The two whole-machine layers ──
// Assigned in setupWholeMachineLayers(), which runs before anything can
// connect. Declared here because every handler below closes over them and
// a `const` at the assignment site would not be visible to code that is
// defined earlier in this function but runs later.
//
// containment -- lib/containment.js. Default-deny outbound, so nothing
// leaves this PC except through the tunnel. Armed with
// the Kill Switch, never on its own.
// tunnel -- lib/tunnel.js. A Wintun adapter plus tun2socks, so apps
// that have never heard of a proxy also ride Tor. Up
// whenever the VPN is connected, if this build carries
// the binaries.
let containment = null;
let tunnel = null;
// Set true once tor.exe has bootstrapped, false again on disconnect. It is
// what tells the tor-death handler whether it is watching a failed connect
// (the connect path reports that itself) or a live session dropping out
// from under the user (which has to fail closed).
let sessionLive = false;
// ── What a .bat run is now allowed to tell us ──
//
// The old version resolved with `undefined` on ANY exit code, so every
// caller was structurally unable to notice a failure. Worse, `cmd.exe /c`
// reports only the LAST command's exit code, so in a ten-line script the
// first nine failures were invisible even to a caller that did look. And
// stdio was 'pipe' with nobody reading it, so netsh's and reg's own error
// text went nowhere.
//
// Two changes, with the remaining limit stated rather than papered over:
// * the result is { ok, code, out } and the output is logged, so a
// failure is at least visible and diagnosable;
// * runBatLines() chains a command LIST with `|| exit /b 1`, which does
// make one exit code mean "every command worked" -- but only for
// scripts that are one command per line, and only where every command